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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions PayBridge.SDK.Test/Unit/IServiceCollectionExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public void AddPayBridge_BindsConfiguration_AndAppliesCodeOverrides_ToSingletonA
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["PaymentGatewayConfig:DefaultGateway"] = "Stripe",
["PaymentGatewayConfig:DefaultGateway"] = "Paystack",
["PaymentGatewayConfig:EnabledGateways:0"] = "Paystack",
["PaymentGatewayConfig:Paystack:SecretKey"] = "sk_from_config"
});
Expand All @@ -35,7 +35,7 @@ public void AddPayBridge_BindsConfiguration_AndAppliesCodeOverrides_ToSingletonA
var config = provider.GetRequiredService<PaymentGatewayConfig>();
var options = provider.GetRequiredService<IOptions<PaymentGatewayConfig>>();

config.DefaultGateway.Should().Be(PaymentGatewayType.Stripe);
config.DefaultGateway.Should().Be(PaymentGatewayType.Paystack);
config.EnabledGateways.Should().ContainSingle().Which.Should().Be(PaymentGatewayType.Paystack);
config.Paystack.SecretKey.Should().Be("sk_from_code");
options.Value.Should().BeSameAs(config);
Expand Down Expand Up @@ -103,6 +103,73 @@ public void AddPayBridge_RegistersWebhookVerifier_AndPreservesCustomTimeProvider
provider.GetRequiredService<TimeProvider>().Should().BeSameAs(customTimeProvider);
}

[Fact]
public void AddPayBridge_WhenEnabledGatewayIsMissingRequiredConfig_Throws()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["PaymentGatewayConfig:EnabledGateways:0"] = "Paystack"
});
var services = new ServiceCollection();
services.AddLogging();

var action = () => services.AddPayBridge(configuration);

action.Should().Throw<Exception>()
.WithMessage("*Paystack*missing required configuration*");
}

[Fact]
public void AddPayBridge_WhenEnabledGatewaysContainsAutomatic_Throws()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["PaymentGatewayConfig:EnabledGateways:0"] = "Automatic"
});
var services = new ServiceCollection();
services.AddLogging();

var action = () => services.AddPayBridge(configuration);

action.Should().Throw<Exception>()
.WithMessage("*cannot include Automatic*");
}

[Fact]
public void AddPayBridge_WhenDefaultGatewayIsMissingRequiredConfig_Throws()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["PaymentGatewayConfig:DefaultGateway"] = "Paystack"
});
var services = new ServiceCollection();
services.AddLogging();

var action = () => services.AddPayBridge(configuration);

action.Should().Throw<Exception>()
.WithMessage("*Default gateway*Paystack*missing required configuration*");
}

[Fact]
public void AddPayBridge_WhenDefaultGatewayIsNotInEnabledGateways_Throws()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["PaymentGatewayConfig:DefaultGateway"] = "Stripe",
["PaymentGatewayConfig:EnabledGateways:0"] = "Paystack",
["PaymentGatewayConfig:Stripe:SecretKey"] = "sk_test_stripe",
["PaymentGatewayConfig:Paystack:SecretKey"] = "sk_test_paystack"
});
var services = new ServiceCollection();
services.AddLogging();

var action = () => services.AddPayBridge(configuration);

action.Should().Throw<Exception>()
.WithMessage("*Default gateway*Stripe*must be included in EnabledGateways*");
}

[Fact]
public void AddPayBridge_RegistersRefundRepository()
{
Expand Down
226 changes: 226 additions & 0 deletions PayBridge.SDK.Test/Unit/PaymentServiceRoutingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using PayBridge.SDK.Application.Dtos;
using PayBridge.SDK.Dtos.Request;
using PayBridge.SDK.Dtos.Response;
using PayBridge.SDK.Enums;
using PayBridge.SDK.Interfaces;
using Xunit;

namespace PayBridge.SDK.Test.Unit;

[Trait("Category", "Unit")]
public class PaymentServiceRoutingTests
{
[Fact]
public async Task Automatic_routing_honors_compatible_configured_default()
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var checkout = Gateway(PaymentGatewayType.Checkout);
var service = CreateService(
new PaymentGatewayConfig { DefaultGateway = PaymentGatewayType.Checkout },
stripe.Object,
checkout.Object);

await service.CreatePaymentAsync(Request("USD"));

checkout.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Once);
stripe.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Never);
}

[Fact]
public async Task Automatic_routing_is_deterministic_across_registration_order()
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var checkout = Gateway(PaymentGatewayType.Checkout);
var service = CreateService(new PaymentGatewayConfig(), checkout.Object, stripe.Object);

await service.CreatePaymentAsync(Request("USD"));

stripe.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Once);
checkout.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Never);
}

[Fact]
public async Task Incompatible_default_is_skipped_for_compatible_gateway()
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var paystack = Gateway(PaymentGatewayType.Paystack);
var service = CreateService(
new PaymentGatewayConfig { DefaultGateway = PaymentGatewayType.Paystack },
paystack.Object,
stripe.Object);

await service.CreatePaymentAsync(Request("USD"));

stripe.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Once);
paystack.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Never);
}

[Fact]
public async Task Unregistered_compatible_default_is_skipped()
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var service = CreateService(
new PaymentGatewayConfig { DefaultGateway = PaymentGatewayType.Checkout },
stripe.Object);

await service.CreatePaymentAsync(Request("USD"));

stripe.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Once);
}

[Fact]
public async Task Single_incompatible_gateway_does_not_bypass_routing_rules()
{
var checkout = Gateway(PaymentGatewayType.Checkout);
var service = CreateService(new PaymentGatewayConfig(), checkout.Object);

var action = () => service.CreatePaymentAsync(Request("NGN"));

await action.Should().ThrowAsync<Exception>().WithMessage("*supports NGN*");
checkout.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Never);
}

[Theory]
[InlineData("ZZZ", PaymentMethodType.Card)]
[InlineData("USD", PaymentMethodType.Crypto)]
[InlineData("NGN", PaymentMethodType.BankTransfer)]
[InlineData("KES", PaymentMethodType.MobileMoney)]
[InlineData("USD", PaymentMethodType.Wallet)]
[InlineData("NGN", PaymentMethodType.Ussd)]
[InlineData("NGN", PaymentMethodType.QrCode)]
public async Task Unsupported_routes_fail_before_provider_call(
string currency,
PaymentMethodType method)
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var service = CreateService(new PaymentGatewayConfig(), stripe.Object);
var request = Request(currency);
request.PaymentMethodType = method;

var action = () => service.CreatePaymentAsync(request);

var exception = await action.Should().ThrowAsync<Exception>();
var message = exception.Which.Message;
(message.Contains("supports", StringComparison.OrdinalIgnoreCase) ||
message.Contains("Specify a gateway explicitly", StringComparison.OrdinalIgnoreCase))
.Should().BeTrue();
stripe.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Never);
}

[Fact]
public async Task Stripe_routes_documented_JPY_currency()
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var service = CreateService(new PaymentGatewayConfig(), stripe.Object);

await service.CreatePaymentAsync(Request("JPY"));

stripe.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Once);
}

[Fact]
public async Task Automatic_routing_trims_currency_before_matching()
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var service = CreateService(new PaymentGatewayConfig(), stripe.Object);

await service.CreatePaymentAsync(Request(" usd "));

stripe.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Once);
}

[Fact]
public async Task Explicit_gateway_rejects_crypto_before_provider_call()
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var service = CreateService(new PaymentGatewayConfig(), stripe.Object);
var request = Request("USD");
request.PaymentMethodType = PaymentMethodType.Crypto;

var action = () => service.CreatePaymentAsync(request, PaymentGatewayType.Stripe);

await action.Should().ThrowAsync<Exception>()
.WithMessage("*payment method Crypto*");
stripe.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Never);
}

[Fact]
public async Task Unsupported_currency_message_uses_trimmed_normalized_currency()
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var service = CreateService(new PaymentGatewayConfig(), stripe.Object);

var action = () => service.CreatePaymentAsync(Request(" zzz "));

await action.Should().ThrowAsync<Exception>()
.WithMessage("*supports ZZZ*");
stripe.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Never);
}

[Fact]
public async Task Automatic_routing_rejects_saved_payment_method_until_binding_is_implemented()
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var service = CreateService(new PaymentGatewayConfig(), stripe.Object);
var request = Request("USD");
request.SavedPaymentMethodId = "pm_saved_123";

var action = () => service.CreatePaymentAsync(request);

await action.Should().ThrowAsync<Exception>()
.WithMessage("*Saved payment method routing is not yet implemented*");
stripe.Verify(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()), Times.Never);
}

[Fact]
public async Task Verify_automatic_routing_rejects_unknown_reference_prefix()
{
var stripe = Gateway(PaymentGatewayType.Stripe);
var transactionRepository = new Mock<ITransactionRepository>();
transactionRepository.Setup(repository => repository.GetByReferenceAsync("UNKNOWN_123"))
.ReturnsAsync((PayBridge.SDK.Entities.PaymentTransaction?)null);
var service = new PaymentService(
transactionRepository.Object,
Mock.Of<IRefundRepository>(),
[stripe.Object],
NullLogger<PaymentService>.Instance,
new PaymentGatewayConfig { DefaultGateway = PaymentGatewayType.Stripe });

var action = () => service.VerifyPaymentAsync("UNKNOWN_123");

await action.Should().ThrowAsync<Exception>()
.WithMessage("*Unable to determine gateway from transaction reference*");
stripe.Verify(item => item.VerifyPaymentAsync(It.IsAny<string>()), Times.Never);
}

private static PaymentService CreateService(
PaymentGatewayConfig config,
params IPaymentGateway[] gateways) =>
new(
Mock.Of<ITransactionRepository>(),
Mock.Of<IRefundRepository>(),
gateways,
NullLogger<PaymentService>.Instance,
config);

private static Mock<IPaymentGateway> Gateway(PaymentGatewayType type)
{
var gateway = new Mock<IPaymentGateway>();
gateway.SetupGet(item => item.GatewayType).Returns(type);
gateway.Setup(item => item.CreatePaymentAsync(It.IsAny<PaymentRequest>()))
.ReturnsAsync(new PaymentResponse { Success = false, Status = PaymentStatus.Failed });
gateway.Setup(item => item.VerifyPaymentAsync(It.IsAny<string>()))
.ReturnsAsync(new VerificationResponse { Success = false, Status = PaymentStatus.Failed });
return gateway;
}

private static PaymentRequest Request(string currency) => new()
{
Amount = 100m,
Currency = currency,
CustomerEmail = "customer@example.test"
};
}
46 changes: 45 additions & 1 deletion PayBridge.SDK/Externsions/IServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using PayBridge.SDK.Application.Dtos;
using PayBridge.SDK.Enums;
using PayBridge.SDK.Exceptions;
using PayBridge.SDK.Interfaces;
using PayBridge.SDK.Services;

Expand Down Expand Up @@ -125,6 +126,8 @@ private static IServiceCollection AddPayBridgeCore(
configuration?.GetSection("PaymentGatewayConfig").Bind(config);
configAction?.Invoke(config);

ValidateDefaultGatewayConfiguration(config);

services.AddSingleton(config);
services.AddSingleton<IOptions<PaymentGatewayConfig>>(Options.Create(config));

Expand Down Expand Up @@ -164,13 +167,54 @@ private static void RegisterGateways(IServiceCollection services, PaymentGateway
return;
}

ValidateEnabledGatewayConfiguration(config);

// Register only the enabled gateways
foreach (var gateway in config.EnabledGateways)
{
RegisterGateway(services, gateway);
}
}

private static void ValidateEnabledGatewayConfiguration(PaymentGatewayConfig config)
{
foreach (var gateway in config.EnabledGateways)
{
if (gateway == PaymentGatewayType.Automatic)
{
throw new PaymentConfigurationException(
"PaymentGatewayConfig:EnabledGateways cannot include Automatic. " +
"Use concrete gateway types only.");
}

if (!IsGatewayConfigured(config, gateway))
{
throw new PaymentConfigurationException(
$"Enabled gateway '{gateway}' is missing required configuration values.");
}
}
}

private static void ValidateDefaultGatewayConfiguration(PaymentGatewayConfig config)
{
if (config.DefaultGateway == PaymentGatewayType.Automatic)
{
return;
}

if (!IsGatewayConfigured(config, config.DefaultGateway))
{
throw new PaymentConfigurationException(
$"Default gateway '{config.DefaultGateway}' is missing required configuration values.");
}

if (config.EnabledGateways.Count > 0 && !config.EnabledGateways.Contains(config.DefaultGateway))
{
throw new PaymentConfigurationException(
$"Default gateway '{config.DefaultGateway}' must be included in EnabledGateways when explicit gateway registration is used.");
}
}

private static void RegisterGateway(IServiceCollection services, PaymentGatewayType gateway)
{
switch (gateway)
Expand Down
Loading
Loading