diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml
index 62a62f1..630b240 100644
--- a/.github/workflows/dotnet-desktop.yml
+++ b/.github/workflows/dotnet-desktop.yml
@@ -68,16 +68,68 @@ jobs:
run_test:
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ framework: ['net8.0', 'net10.0']
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: '8.0.x'
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+
+ - name: Restore
+ run: dotnet restore SaferPay.Tests/SaferPay.Tests.csproj
+
+ - name: Run unit tests (${{ matrix.framework }})
+ run: >-
+ dotnet test SaferPay.Tests/SaferPay.Tests.csproj
+ --configuration Release
+ --framework ${{ matrix.framework }}
+ --filter "FullyQualifiedName!~Integration"
+ --logger "trx;LogFileName=unit-${{ matrix.framework }}.trx"
+ --collect:"XPlat Code Coverage"
+ --results-directory ${{ github.workspace }}/TestResults
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-${{ matrix.framework }}
+ path: ${{ github.workspace }}/TestResults
+ if-no-files-found: warn
+ retention-days: 7
+
+ run_integration_tests:
+ # Hits the real test.saferpay.com sandbox using the public Viwo test account
+ # that's already shipped in SaferPay.Test/TestConfig.cs. Repo secrets, if
+ # configured, override the defaults via env vars. Runs on every PR — the
+ # sandbox is rate-friendly and the suite takes ~3s.
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
- - name: Run tests
- run: dotnet test --configuration Release
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Run sandbox integration tests
+ env:
+ SAFERPAY_CUSTOMER_ID: ${{ secrets.SAFERPAY_CUSTOMER_ID }}
+ SAFERPAY_TERMINAL_ID: ${{ secrets.SAFERPAY_TERMINAL_ID }}
+ SAFERPAY_USERNAME: ${{ secrets.SAFERPAY_USERNAME }}
+ SAFERPAY_PASSWORD: ${{ secrets.SAFERPAY_PASSWORD }}
+ run: >-
+ dotnet test SaferPay.Tests/SaferPay.Tests.csproj
+ --configuration Release
+ --framework net10.0
+ --filter "FullyQualifiedName~Integration"
+ --logger "console;verbosity=normal"
deploy:
if: github.event_name == 'release'
diff --git a/Models/Core/RegisterAlias.cs b/Models/Core/RegisterAlias.cs
index 3c426d8..53533da 100644
--- a/Models/Core/RegisterAlias.cs
+++ b/Models/Core/RegisterAlias.cs
@@ -27,6 +27,8 @@ public class RegisterAlias
/// Range: inclusive between 1 and 1600
/// Example: 1000
///
- public int Lifetime { get; set; }
+ // Nullable: Saferpay rejects 0 with "must be between 1 and 1600", so the
+ // field must be omittable when the caller wants the documented default.
+ public int? Lifetime { get; set; }
}
\ No newline at end of file
diff --git a/Models/SecureData/AliasInsertRequest.cs b/Models/SecureData/AliasInsertRequest.cs
index 111fe88..a216105 100644
--- a/Models/SecureData/AliasInsertRequest.cs
+++ b/Models/SecureData/AliasInsertRequest.cs
@@ -32,18 +32,23 @@ public class AliasInsertRequest : RequestBase
///
/// Language used for displaying forms.
///
- public LanguageCodes LanguageCode { get; set; }
+ // Nullable: see Check below.
+ public LanguageCodes? LanguageCode { get; set; }
///
/// Parameters for checking the means of payment before registering.
///
- public CheckTypes Check { get; set; }
+ // Nullable: RequestBase.Json() only strips nulls, so a non-nullable enum
+ // always serializes with its default (0) — which Saferpay rejects as
+ // "field is invalid" on AliasInsert. Marking nullable lets callers omit it.
+ public CheckTypes? Check { get; set; }
///
/// Used to restrict the means of payment which are available to the payer
/// AMEX, BONUS, DINERS, DIRECTDEBIT, JCB, MAESTRO, MASTERCARD, MYONE, VISA
///
- public AliasPaymentMethods PaymentMethods { get; set; }
+ // Nullable: see Check above.
+ public AliasPaymentMethods? PaymentMethods { get; set; }
///
/// Options for card data entry form (if applicable)
diff --git a/SaferPay.Tests/GlobalUsings.cs b/SaferPay.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..91743bb
--- /dev/null
+++ b/SaferPay.Tests/GlobalUsings.cs
@@ -0,0 +1,2 @@
+global using Xunit;
+global using FluentAssertions;
diff --git a/SaferPay.Tests/Integration/BatchAndOmniChannelSandboxTests.cs b/SaferPay.Tests/Integration/BatchAndOmniChannelSandboxTests.cs
new file mode 100644
index 0000000..debf6f2
--- /dev/null
+++ b/SaferPay.Tests/Integration/BatchAndOmniChannelSandboxTests.cs
@@ -0,0 +1,52 @@
+using SaferPay.Models.Batch;
+using SaferPay.Models.OmniChannel;
+
+namespace SaferPay.Tests.Integration;
+
+[Trait("Category", "Integration")]
+public class BatchAndOmniChannelSandboxTests
+{
+ [SandboxFact]
+ public async Task Batch_Close_OnInvalidTerminal_ReturnsErrorEnvelope()
+ {
+ // Exercises the Batch channel pipeline end-to-end. We don't close the
+ // real sandbox batch (side-effect-y) — instead we send a bogus terminal
+ // and assert the SDK surfaces a structured error.
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = false;
+
+ using var client = new SaferPayClient(settings);
+
+ var response = await client.Batch.CloseAsync(new BatchRequest
+ {
+ TerminalId = "00000000",
+ });
+
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeFalse();
+ response.Error.Should().NotBeNull();
+ response.Error!.ErrorMessage.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [SandboxFact]
+ public async Task OmniChannel_AcquireTransaction_OnUnknownReference_ReturnsErrorEnvelope()
+ {
+ // Exercises the OmniChannel pipeline + AcquireTransactionRequest
+ // serialization (OrderId + TerminalId + SixTransactionReference).
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = false;
+
+ using var client = new SaferPayClient(settings);
+
+ var response = await client.OmniChannel.AcquireTransactionAsync(new AcquireTransactionRequest
+ {
+ TerminalId = settings.TerminalId,
+ OrderId = $"it-oc-{Guid.NewGuid():n}",
+ SixTransactionReference = "0:000000:000000000000",
+ });
+
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeFalse();
+ response.Error.Should().NotBeNull();
+ }
+}
diff --git a/SaferPay.Tests/Integration/ManagementApiSandboxTests.cs b/SaferPay.Tests/Integration/ManagementApiSandboxTests.cs
new file mode 100644
index 0000000..584d853
--- /dev/null
+++ b/SaferPay.Tests/Integration/ManagementApiSandboxTests.cs
@@ -0,0 +1,76 @@
+namespace SaferPay.Tests.Integration;
+
+///
+/// Exercises the REST pipeline in
+/// (Get / Post / Delete + HandleErrorResponse + ProcessFailure) against the
+/// real sandbox. The whole branch is otherwise untested — channel-style
+/// SOAP-like POSTs hit a different code path.
+///
+[Trait("Category", "Integration")]
+public class ManagementApiSandboxTests
+{
+ [SandboxFact]
+ public async Task TerminalGetTerminal_ReturnsTerminalInfo()
+ {
+ // Exercises Get(path) on a known-good endpoint:
+ // - URL composition (BaseUri + rest/customers/{cid}/terminals/{tid})
+ // - Basic auth
+ // - Saferpay-ApiVersion + Saferpay-RequestId headers
+ // - 200 OK branch + JSON deserialization
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = true;
+
+ using var client = new SaferPayClient(settings);
+
+ var response = await client.ManagementApi.TerminalGetTerminalAsync();
+
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeTrue($"sandbox returned: {response.Error?.ErrorMessage}");
+ response.TerminalId.Should().Be(settings.TerminalId);
+ }
+
+ [SandboxFact]
+ public async Task LicensingCustomerLicense_ReturnsLicenseInfo()
+ {
+ // Second Get(path), different shape — guards against
+ // accidental coupling between deserialization and a single response type.
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = true;
+
+ using var client = new SaferPayClient(settings);
+
+ var response = await client.ManagementApi.LicensingCustomerLicenseAsync();
+
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeTrue($"sandbox returned: {response.Error?.ErrorMessage}");
+ }
+
+ [SandboxFact]
+ public async Task SecurePayGate_SingleUsePaymentLink_OnUnknownOffer_ReturnsErrorEnvelope()
+ {
+ // Exercises the 404 branch in Get:
+ // - "Requested resource not found (404)." synthetic ErrorResponse
+ // - ThrowExceptionOnFail=false → returned in envelope, not thrown
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = false;
+
+ using var client = new SaferPayClient(settings);
+
+ var response = await client.ManagementApi.SecurePayGateSingleUsePaymentLinkAsync(
+ Guid.NewGuid().ToString());
+
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeFalse();
+ response.Error.Should().NotBeNull();
+ response.Error!.ErrorMessage.Should().NotBeNullOrWhiteSpace();
+ }
+
+ // NOTE: a Delete test would naturally go here, but the only
+ // ManagementApi method that wires DeleteAsync — SaferpayFieldsAccessToken
+ // DeleteAccessTokenAsync — declares Task, and
+ // RestResponseBase is abstract. The SDK's error path runs
+ // Activator.CreateInstance() on failure, which crashes with
+ // MissingMethodException on an abstract type. Method is therefore
+ // unsendable as-is; covering it requires fixing the channel signature
+ // first (e.g. introducing a concrete AccessTokenDeleteResponse).
+}
diff --git a/SaferPay.Tests/Integration/PaymentPageSandboxTests.cs b/SaferPay.Tests/Integration/PaymentPageSandboxTests.cs
new file mode 100644
index 0000000..3c024b7
--- /dev/null
+++ b/SaferPay.Tests/Integration/PaymentPageSandboxTests.cs
@@ -0,0 +1,148 @@
+using SaferPay.Exceptions;
+using SaferPay.Models.Core;
+using SaferPay.Models.PaymentPage;
+
+namespace SaferPay.Tests.Integration;
+
+[Trait("Category", "Integration")]
+public class PaymentPageSandboxTests
+{
+ [SandboxFact]
+ public async Task Initialize_SuccessPath_DeserializesTokenAndRedirectUrl()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = true;
+
+ using var client = new SaferPayClient(settings);
+
+ var orderId = $"it-pp-{Guid.NewGuid():n}";
+ var request = new InitializePaymentPageRequest(
+ terminalId: settings.TerminalId,
+ amount: 1.00m,
+ currencyCode: "CHF",
+ orderId: orderId,
+ returnURL: "https://example.com/return");
+ request.Payment.Description = "Integration test";
+
+ var response = await client.PaymentPage.InitializeAsync(request);
+
+ // Validates the entire success pipeline:
+ // - HTTP Basic auth, RequestId header, JSON body serialization
+ // - 200 OK branch in SaferPayClient.SendAsync
+ // - ResponseStatus.SUCCESS assignment
+ // - Newtonsoft.Json deserialization of nested response shape
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeTrue($"sandbox returned error: {response.Error?.ErrorMessage}");
+ response.Token.Should().NotBeNullOrWhiteSpace();
+ response.RedirectUrl.Should().StartWith("https://test.saferpay.com/");
+ response.Expiration.Should().BeAfter(DateTimeOffset.UtcNow);
+ response.ResponseHeader.Should().NotBeNull();
+ response.ResponseHeader.RequestId.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [SandboxFact]
+ public async Task Initialize_BadAmount_WithoutThrow_ReturnsStructuredError()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = false;
+
+ using var client = new SaferPayClient(settings);
+
+ // Negative amount → sandbox rejects with VALIDATION_FAILED.
+ var request = new InitializePaymentPageRequest(
+ terminalId: settings.TerminalId,
+ amount: -1m,
+ currencyCode: "CHF",
+ orderId: $"it-pp-bad-{Guid.NewGuid():n}",
+ returnURL: "https://example.com/return");
+
+ var response = await client.PaymentPage.InitializeAsync(request);
+
+ // Validates the error pipeline with ThrowExceptionOnFail=false:
+ // - non-200 branch in SaferPayClient.SendAsync
+ // - ErrorResponse deserialization
+ // - ResponseStatus.ERROR assignment, Error property populated
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeFalse();
+ response.Error.Should().NotBeNull();
+ response.Error!.ErrorMessage.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [SandboxFact]
+ public async Task Initialize_BadAmount_WithThrow_ThrowsTypedException()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = true;
+
+ using var client = new SaferPayClient(settings);
+
+ var request = new InitializePaymentPageRequest(
+ terminalId: settings.TerminalId,
+ amount: -1m,
+ currencyCode: "CHF",
+ orderId: $"it-pp-bad-{Guid.NewGuid():n}",
+ returnURL: "https://example.com/return");
+
+ // Validates that the ThrowExceptionOnFail=true branch surfaces a typed
+ // SaferPayException carrying both HTTP status and the parsed ErrorResponse.
+ var act = () => client.PaymentPage.InitializeAsync(request);
+
+ var ex = (await act.Should().ThrowAsync()).Which;
+ ex.HttpStatusCode.Should().Be(System.Net.HttpStatusCode.BadRequest);
+ ex.ErrorResponse.Should().NotBeNull();
+ ex.ErrorResponse.ErrorMessage.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [SandboxFact]
+ public async Task Initialize_WithBadCredentials_ReturnsAuthError()
+ {
+ // Validates HTTP Basic auth + 401 handling.
+ var settings = new SaferPaySettings(
+ customerId: SandboxEnvironment.BuildSettings().CustomerId,
+ terminalId: SandboxEnvironment.BuildSettings().TerminalId,
+ userName: "API_INVALID",
+ passWord: "INVALID",
+ sandBox: true)
+ {
+ ThrowExceptionOnFail = false,
+ };
+
+ using var client = new SaferPayClient(settings);
+
+ var request = new InitializePaymentPageRequest(
+ terminalId: settings.TerminalId,
+ amount: 1m,
+ currencyCode: "CHF",
+ orderId: $"it-pp-auth-{Guid.NewGuid():n}",
+ returnURL: "https://example.com/return");
+
+ var response = await client.PaymentPage.InitializeAsync(request);
+
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeFalse();
+ response.Error.Should().NotBeNull();
+ }
+
+ [SandboxFact]
+ public async Task Assert_OnFreshUnredirectedToken_ReturnsErrorEnvelope()
+ {
+ // The payer hasn't been through the redirect flow, so Assert is expected
+ // to error out. We only verify the envelope is well-formed, not the verdict.
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = false;
+
+ using var client = new SaferPayClient(settings);
+
+ var initRequest = new InitializePaymentPageRequest(
+ settings.TerminalId, 1m, "CHF", $"it-pp-{Guid.NewGuid():n}", "https://example.com/return");
+ initRequest.Payment.Description = "Integration test";
+
+ var init = await client.PaymentPage.InitializeAsync(initRequest);
+ init.IsSuccess.Should().BeTrue();
+
+ var assert = await client.PaymentPage.AssertAsync(new AssertRequest(init.Token));
+
+ assert.Should().NotBeNull();
+ (assert.IsSuccess || assert.Error is not null).Should().BeTrue();
+ }
+}
diff --git a/SaferPay.Tests/Integration/SandboxEnvironment.cs b/SaferPay.Tests/Integration/SandboxEnvironment.cs
new file mode 100644
index 0000000..9854546
--- /dev/null
+++ b/SaferPay.Tests/Integration/SandboxEnvironment.cs
@@ -0,0 +1,56 @@
+using SaferPay.Models.Core;
+
+namespace SaferPay.Tests.Integration;
+
+///
+/// Resolves SaferPay sandbox credentials for integration tests.
+///
+/// Lookup order:
+/// 1. Environment variables (SAFERPAY_CUSTOMER_ID / TERMINAL_ID / USERNAME / PASSWORD)
+/// — lets users / forks override with their own sandbox account.
+/// 2. The public Viwo sandbox account, already published in this repository at
+/// SaferPay.Test/TestConfig.cs. Mirrored here so CI can exercise the real HTTP
+/// pipeline against test.saferpay.com on every PR without provisioning secrets.
+///
+/// Escape hatch: setting SAFERPAY_SKIP_INTEGRATION=1 (e.g. for fully-offline runs)
+/// makes IsAvailable return false and the SandboxFact attribute skip the tests.
+///
+internal static class SandboxEnvironment
+{
+ public const string CustomerIdVar = "SAFERPAY_CUSTOMER_ID";
+ public const string TerminalIdVar = "SAFERPAY_TERMINAL_ID";
+ public const string UsernameVar = "SAFERPAY_USERNAME";
+ public const string PasswordVar = "SAFERPAY_PASSWORD";
+ public const string SkipVar = "SAFERPAY_SKIP_INTEGRATION";
+
+ // Public sandbox account shipped by Viwo with the playground. Same values as
+ // SaferPay.Test/TestConfig.cs. Not a secret — already committed on main.
+ private const string DefaultCustomerId = "268079";
+ private const string DefaultTerminalId = "17757286";
+ private const string DefaultUsername = "API_268079_69541955";
+ private const string DefaultPassword = "!S4f3Rp4%y?0_T35T";
+
+ public static bool IsAvailable
+ {
+ get
+ {
+ if (string.Equals(Get(SkipVar), "1", StringComparison.Ordinal)) return false;
+ // Defaults always satisfy the contract, so the only way to be unavailable
+ // is the explicit skip opt-out above.
+ return true;
+ }
+ }
+
+ public static SaferPaySettings BuildSettings() => new(
+ customerId: Get(CustomerIdVar) ?? DefaultCustomerId,
+ terminalId: Get(TerminalIdVar) ?? DefaultTerminalId,
+ userName: Get(UsernameVar) ?? DefaultUsername,
+ passWord: Get(PasswordVar) ?? DefaultPassword,
+ sandBox: true);
+
+ private static string? Get(string name)
+ {
+ var v = Environment.GetEnvironmentVariable(name);
+ return string.IsNullOrWhiteSpace(v) ? null : v;
+ }
+}
diff --git a/SaferPay.Tests/Integration/SandboxFactAttribute.cs b/SaferPay.Tests/Integration/SandboxFactAttribute.cs
new file mode 100644
index 0000000..c7d3135
--- /dev/null
+++ b/SaferPay.Tests/Integration/SandboxFactAttribute.cs
@@ -0,0 +1,17 @@
+namespace SaferPay.Tests.Integration;
+
+///
+/// xUnit for tests that hit the real SaferPay sandbox.
+/// Auto-skips when is set, which is the
+/// escape hatch for fully-offline runs.
+///
+public sealed class SandboxFactAttribute : FactAttribute
+{
+ public SandboxFactAttribute()
+ {
+ if (!SandboxEnvironment.IsAvailable)
+ {
+ Skip = $"{SandboxEnvironment.SkipVar}=1 set, skipping live-sandbox test.";
+ }
+ }
+}
diff --git a/SaferPay.Tests/Integration/SecureCardDataSandboxTests.cs b/SaferPay.Tests/Integration/SecureCardDataSandboxTests.cs
new file mode 100644
index 0000000..cbef22c
--- /dev/null
+++ b/SaferPay.Tests/Integration/SecureCardDataSandboxTests.cs
@@ -0,0 +1,126 @@
+using SaferPay.Enums;
+using SaferPay.Exceptions;
+using SaferPay.Models.Core;
+using SaferPay.Models.SecureData;
+
+namespace SaferPay.Tests.Integration;
+
+[Trait("Category", "Integration")]
+public class SecureCardDataSandboxTests
+{
+ [SandboxFact]
+ public async Task AliasInsert_SuccessPath_ReturnsTokenAndRedirect()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = true;
+
+ using var client = new SaferPayClient(settings);
+
+ var request = new AliasInsertRequest
+ {
+ // RANDOM_UNIQUE avoids having to invent a unique alias id ourselves.
+ // Lifetime left null on purpose: relies on the Saferpay default
+ // (1096 days) and exercises the nullable-int omission path.
+ RegisterAlias = new RegisterAlias { IdGenerator = IdGeneratorTypes.RANDOM_UNIQUE },
+ Type = AliasType.CARD,
+ ReturnUrl = new ReturnUrl("https://example.com/return"),
+ };
+
+ var response = await client.SecureCardData.AliasInsertAsync(request);
+
+ // Validates the alias-insert pipeline end-to-end:
+ // - request serialization (RegisterAlias + ReturnUrl + enum values)
+ // - 200 OK branch in SaferPayClient.SendAsync
+ // - AliasInsertResponse deserialization (Token + Redirect + Expiration)
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeTrue($"sandbox returned: {response.Error?.ErrorMessage}");
+ response.Token.Should().NotBeNullOrWhiteSpace();
+ response.RedirectRequired.Should().BeTrue();
+ response.Redirect.Should().NotBeNull();
+ response.Redirect.RedirectUrl.Should().StartWith("https://test.saferpay.com/");
+ response.Expiration.Should().BeAfter(DateTimeOffset.UtcNow);
+ }
+
+ [SandboxFact]
+ public async Task AliasInsert_WithBadCredentials_ReturnsAuthError()
+ {
+ var baseline = SandboxEnvironment.BuildSettings();
+ var settings = new SaferPaySettings(
+ customerId: baseline.CustomerId,
+ terminalId: baseline.TerminalId,
+ userName: "API_INVALID",
+ passWord: "INVALID",
+ sandBox: true)
+ {
+ ThrowExceptionOnFail = false,
+ };
+
+ using var client = new SaferPayClient(settings);
+
+ var response = await client.SecureCardData.AliasInsertAsync(new AliasInsertRequest
+ {
+ RegisterAlias = new RegisterAlias { IdGenerator = IdGeneratorTypes.RANDOM_UNIQUE, Lifetime = 1000 },
+ Type = AliasType.CARD,
+ ReturnUrl = new ReturnUrl("https://example.com/return"),
+ });
+
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeFalse();
+ response.Error.Should().NotBeNull();
+ }
+
+ [SandboxFact]
+ public async Task AliasInquire_OnUnknownAliasId_ReturnsErrorEnvelope()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = false;
+
+ using var client = new SaferPayClient(settings);
+
+ var response = await client.SecureCardData.AliasInquireAsync(new AliasInquireRequest
+ {
+ AliasId = "does-not-exist-" + Guid.NewGuid().ToString("n"),
+ });
+
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeFalse();
+ response.Error.Should().NotBeNull();
+ response.Error!.ErrorMessage.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [SandboxFact]
+ public async Task AliasInquire_OnUnknownAliasId_WithThrow_ThrowsTypedException()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = true;
+
+ using var client = new SaferPayClient(settings);
+
+ var act = () => client.SecureCardData.AliasInquireAsync(new AliasInquireRequest
+ {
+ AliasId = "does-not-exist-" + Guid.NewGuid().ToString("n"),
+ });
+
+ var ex = (await act.Should().ThrowAsync()).Which;
+ ex.ErrorResponse.Should().NotBeNull();
+ ((int)ex.HttpStatusCode).Should().BeInRange(400, 599);
+ }
+
+ [SandboxFact]
+ public async Task AliasDelete_OnUnknownAliasId_ReturnsErrorEnvelope()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = false;
+
+ using var client = new SaferPayClient(settings);
+
+ var response = await client.SecureCardData.AliasDeleteAsync(new AliasDeleteRequest
+ {
+ AliasId = "does-not-exist-" + Guid.NewGuid().ToString("n"),
+ });
+
+ response.Should().NotBeNull();
+ response.IsSuccess.Should().BeFalse();
+ response.Error.Should().NotBeNull();
+ }
+}
diff --git a/SaferPay.Tests/Integration/TransactionFlowSandboxTests.cs b/SaferPay.Tests/Integration/TransactionFlowSandboxTests.cs
new file mode 100644
index 0000000..704fd4c
--- /dev/null
+++ b/SaferPay.Tests/Integration/TransactionFlowSandboxTests.cs
@@ -0,0 +1,96 @@
+using SaferPay.Exceptions;
+using SaferPay.Models.Transaction;
+
+namespace SaferPay.Tests.Integration;
+
+[Trait("Category", "Integration")]
+public class TransactionFlowSandboxTests
+{
+ [SandboxFact]
+ public async Task Initialize_SuccessPath_ReturnsTokenAndRedirect()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = true;
+
+ using var client = new SaferPayClient(settings);
+
+ var init = await client.Transaction.InitializeAsync(new InitializeRequest(
+ settings.TerminalId, 1.00m, "CHF",
+ $"it-tx-{Guid.NewGuid():n}", "https://example.com/return"));
+
+ init.Should().NotBeNull();
+ init.IsSuccess.Should().BeTrue($"sandbox returned: {init.Error?.ErrorMessage}");
+ init.Token.Should().NotBeNullOrWhiteSpace();
+ init.Expiration.Should().BeAfter(DateTimeOffset.UtcNow);
+ }
+
+ [SandboxFact]
+ public async Task Authorize_OnUnredirectedToken_ReturnsErrorEnvelope()
+ {
+ // Without the payer going through 3DS / card form, Authorize is expected
+ // to fail. We assert the error is surfaced as data, not a crash.
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = false;
+
+ using var client = new SaferPayClient(settings);
+
+ var init = await client.Transaction.InitializeAsync(new InitializeRequest(
+ settings.TerminalId, 1.00m, "CHF",
+ $"it-tx-{Guid.NewGuid():n}", "https://example.com/return"));
+ init.IsSuccess.Should().BeTrue();
+
+ var auth = await client.Transaction.AuthorizeAsync(new AuthorizeRequest(init.Token));
+
+ auth.Should().NotBeNull();
+ (auth.IsSuccess || auth.Error is not null).Should().BeTrue();
+ }
+
+ [SandboxFact]
+ public async Task Capture_OnUnknownTransaction_ReturnsErrorEnvelope()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = false;
+
+ using var client = new SaferPayClient(settings);
+
+ var result = await client.Transaction.CaptureAsync(
+ new CaptureRequest("does-not-exist-" + Guid.NewGuid().ToString("n")));
+
+ result.Should().NotBeNull();
+ result.IsSuccess.Should().BeFalse();
+ result.Error.Should().NotBeNull();
+ result.Error!.ErrorMessage.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [SandboxFact]
+ public async Task Capture_OnUnknownTransaction_WithThrow_ThrowsTypedException()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = true;
+
+ using var client = new SaferPayClient(settings);
+
+ var act = () => client.Transaction.CaptureAsync(
+ new CaptureRequest("does-not-exist-" + Guid.NewGuid().ToString("n")));
+
+ var ex = (await act.Should().ThrowAsync()).Which;
+ ex.ErrorResponse.Should().NotBeNull();
+ ((int)ex.HttpStatusCode).Should().BeInRange(400, 599);
+ }
+
+ [SandboxFact]
+ public async Task Inquire_OnUnknownTransaction_ReturnsErrorEnvelope()
+ {
+ var settings = SandboxEnvironment.BuildSettings();
+ settings.ThrowExceptionOnFail = false;
+
+ using var client = new SaferPayClient(settings);
+
+ var result = await client.Transaction.InquireAsync(
+ new InquireRequest("does-not-exist-" + Guid.NewGuid().ToString("n")));
+
+ result.Should().NotBeNull();
+ result.IsSuccess.Should().BeFalse();
+ result.Error.Should().NotBeNull();
+ }
+}
diff --git a/SaferPay.Tests/SaferPay.Tests.csproj b/SaferPay.Tests/SaferPay.Tests.csproj
new file mode 100644
index 0000000..3258511
--- /dev/null
+++ b/SaferPay.Tests/SaferPay.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net8.0;net10.0
+ enable
+ enable
+ false
+ true
+ latest
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SaferPay.Tests/Unit/AliasInsertRequestTests.cs b/SaferPay.Tests/Unit/AliasInsertRequestTests.cs
new file mode 100644
index 0000000..91d317b
--- /dev/null
+++ b/SaferPay.Tests/Unit/AliasInsertRequestTests.cs
@@ -0,0 +1,55 @@
+using Newtonsoft.Json.Linq;
+using SaferPay.Enums;
+using SaferPay.Models.Core;
+using SaferPay.Models.SecureData;
+
+namespace SaferPay.Tests.Unit;
+
+///
+/// Wire-contract tests for serialization.
+/// Locks in the fix where Lifetime / Check / PaymentMethods / LanguageCode
+/// became nullable so Saferpay no longer rejects the default-0 enum values.
+///
+public class AliasInsertRequestTests
+{
+ [Fact]
+ public void Json_OmitsNullableFields_WhenUnset()
+ {
+ var req = new AliasInsertRequest
+ {
+ RegisterAlias = new RegisterAlias { IdGenerator = IdGeneratorTypes.RANDOM_UNIQUE },
+ Type = AliasType.CARD,
+ ReturnUrl = new ReturnUrl("https://shop/return"),
+ };
+
+ var jo = JObject.Parse(req.Json());
+
+ jo.ContainsKey("Check").Should().BeFalse();
+ jo.ContainsKey("PaymentMethods").Should().BeFalse();
+ jo.ContainsKey("LanguageCode").Should().BeFalse();
+ jo["RegisterAlias"]!["Lifetime"].Should().BeNull();
+ }
+
+ [Fact]
+ public void Json_SerializesNullableFields_WhenSet()
+ {
+ var req = new AliasInsertRequest
+ {
+ RegisterAlias = new RegisterAlias
+ {
+ IdGenerator = IdGeneratorTypes.RANDOM_UNIQUE,
+ Lifetime = 500,
+ },
+ Type = AliasType.CARD,
+ ReturnUrl = new ReturnUrl("https://shop/return"),
+ Check = CheckTypes.ONLINE,
+ PaymentMethods = AliasPaymentMethods.VISA,
+ };
+
+ var jo = JObject.Parse(req.Json());
+
+ jo["Check"]!.Value().Should().Be("ONLINE");
+ jo["PaymentMethods"]!.Value().Should().Be("VISA");
+ jo["RegisterAlias"]!["Lifetime"]!.Value().Should().Be(500);
+ }
+}
diff --git a/SaferPay.Tests/Unit/AmountTests.cs b/SaferPay.Tests/Unit/AmountTests.cs
new file mode 100644
index 0000000..36ec48d
--- /dev/null
+++ b/SaferPay.Tests/Unit/AmountTests.cs
@@ -0,0 +1,35 @@
+using SaferPay.Enums;
+using SaferPay.Models.Core;
+
+namespace SaferPay.Tests.Unit;
+
+public class AmountTests
+{
+ [Theory]
+ [InlineData(1.00, "100")]
+ [InlineData(12.34, "1234")]
+ [InlineData(0.05, "5")]
+ [InlineData(1000, "100000")]
+ public void Ctor_DecimalString_ConvertsToMinorUnits(decimal value, string expectedMinor)
+ {
+ var a = new Amount(value, "CHF");
+ a.Value.Should().Be(expectedMinor);
+ a.CurrencyCode.Should().Be("CHF");
+ }
+
+ [Fact]
+ public void Ctor_CurrencyEnum_UppercasesCode()
+ {
+ var a = new Amount(9.99m, CurrencyCodes.EUR);
+ a.Value.Should().Be("999");
+ a.CurrencyCode.Should().Be("EUR");
+ }
+
+ [Fact]
+ public void ToString_FormatsValueAndCurrency()
+ {
+ // ToString parses the *minor-unit* value back as a decimal then formats with 2 decimals.
+ var a = new Amount { Value = "1234", CurrencyCode = "USD" };
+ a.ToString().Should().EndWith("USD");
+ }
+}
diff --git a/SaferPay.Tests/Unit/AssertRequestTests.cs b/SaferPay.Tests/Unit/AssertRequestTests.cs
new file mode 100644
index 0000000..2bb9f23
--- /dev/null
+++ b/SaferPay.Tests/Unit/AssertRequestTests.cs
@@ -0,0 +1,18 @@
+using SaferPay.Models.PaymentPage;
+
+namespace SaferPay.Tests.Unit;
+
+public class AssertRequestTests
+{
+ [Fact]
+ public void Ctor_StoresToken()
+ {
+ new AssertRequest("tok").Token.Should().Be("tok");
+ }
+
+ [Fact]
+ public void ToString_ContainsToken()
+ {
+ new AssertRequest("abc").ToString().Should().Contain("abc");
+ }
+}
diff --git a/SaferPay.Tests/Unit/BatchAndOmniChannelRoutingTests.cs b/SaferPay.Tests/Unit/BatchAndOmniChannelRoutingTests.cs
new file mode 100644
index 0000000..72bea1d
--- /dev/null
+++ b/SaferPay.Tests/Unit/BatchAndOmniChannelRoutingTests.cs
@@ -0,0 +1,35 @@
+using SaferPay.Channels;
+using SaferPay.Models.Batch;
+using SaferPay.Models.OmniChannel;
+
+namespace SaferPay.Tests.Unit;
+
+public class BatchAndOmniChannelRoutingTests
+{
+ [Fact]
+ public async Task Batch_CloseAsync_RoutesTo_BatchClose()
+ {
+ var fake = new RecordingSaferPayClient();
+ var channel = new Batch(fake);
+ await channel.CloseAsync(new BatchRequest());
+ fake.Calls.Should().ContainSingle().Which.Path.Should().Be("Payment/v1/Batch/Close");
+ }
+
+ [Fact]
+ public async Task OmniChannel_AcquireTransactionAsync_RoutesTo_AcquireTransaction()
+ {
+ var fake = new RecordingSaferPayClient();
+ var channel = new OmniChannel(fake);
+ await channel.AcquireTransactionAsync(new AcquireTransactionRequest());
+ fake.Calls.Should().ContainSingle().Which.Path.Should().Be("Payment/v1/OmniChannel/AcquireTransaction");
+ }
+
+ [Fact]
+ public async Task OmniChannel_InsertAliasAsync_RoutesTo_InsertAlias()
+ {
+ var fake = new RecordingSaferPayClient();
+ var channel = new OmniChannel(fake);
+ await channel.InsertAliasAsync(new InsertAliasRequest());
+ fake.Calls.Should().ContainSingle().Which.Path.Should().Be("Payment/v1/OmniChannel/InsertAlias");
+ }
+}
diff --git a/SaferPay.Tests/Unit/CardTests.cs b/SaferPay.Tests/Unit/CardTests.cs
new file mode 100644
index 0000000..73de542
--- /dev/null
+++ b/SaferPay.Tests/Unit/CardTests.cs
@@ -0,0 +1,41 @@
+using SaferPay.Models.Core;
+
+namespace SaferPay.Tests.Unit;
+
+public class CardTests
+{
+ [Fact]
+ public void Ctor_StripsWhitespaceFromNumber()
+ {
+ var c = new Card("4242 4242 4242 4242", 9, 2030, "123", "John");
+ c.Number.Should().Be("4242424242424242");
+ }
+
+ [Fact]
+ public void Ctor_TwoDigitYear_ExpandsTo20xx()
+ {
+ var c = new Card("4242424242424242", 12, 30, "123");
+ c.ExpYear.Should().Be(2030);
+ }
+
+ [Fact]
+ public void Ctor_FourDigitYear_IsPreserved()
+ {
+ var c = new Card("4242424242424242", 12, 2035, "123");
+ c.ExpYear.Should().Be(2035);
+ }
+
+ [Fact]
+ public void ExpYear_Setter_NormalizesTwoDigitInput()
+ {
+ var c = new Card { ExpYear = 28 };
+ c.ExpYear.Should().Be(2028);
+ }
+
+ [Fact]
+ public void Ctor_NumericVerificationCode_IsStored_AsString()
+ {
+ var c = new Card("4242424242424242", 1, 2031, 999, "J");
+ c.VerificationCode.Should().Be("999");
+ }
+}
diff --git a/SaferPay.Tests/Unit/CreditCardExpirationTests.cs b/SaferPay.Tests/Unit/CreditCardExpirationTests.cs
new file mode 100644
index 0000000..b69019c
--- /dev/null
+++ b/SaferPay.Tests/Unit/CreditCardExpirationTests.cs
@@ -0,0 +1,40 @@
+namespace SaferPay.Tests.Unit;
+
+public class CreditCardExpirationTests
+{
+ [Theory]
+ [InlineData(2025, 9, "0925")]
+ [InlineData(2030, 12, "1230")]
+ [InlineData(99, 1, "0199")] // already 2-digit year
+ [InlineData(2000, 7, "0700")]
+ public void ToString_FormatsMMYY(int year, int month, string expected)
+ {
+ new CreditCardExpiration(year, month).ToString().Should().Be(expected);
+ }
+
+ [Fact]
+ public void Ctor_NormalizesFourDigitYearToTwo()
+ {
+ var exp = new CreditCardExpiration(2027, 5);
+ exp.Year.Should().Be(27);
+ exp.Month.Should().Be(5);
+ }
+
+ [Theory]
+ [InlineData("0925", 9, 25)]
+ [InlineData("12/30", 12, 30)]
+ [InlineData("01-99", 1, 99)]
+ public void Parse_StripsNonDigitsAndExtractsMonthYear(string input, int month, int year)
+ {
+ var exp = CreditCardExpiration.Parse(input);
+ exp.Month.Should().Be(month);
+ exp.Year.Should().Be(year);
+ }
+
+ [Fact]
+ public void Setters_RenormalizeYear()
+ {
+ var exp = new CreditCardExpiration(2025, 1) { Year = 2031 };
+ exp.Year.Should().Be(31);
+ }
+}
diff --git a/SaferPay.Tests/Unit/EndpointsTests.cs b/SaferPay.Tests/Unit/EndpointsTests.cs
new file mode 100644
index 0000000..ab0999d
--- /dev/null
+++ b/SaferPay.Tests/Unit/EndpointsTests.cs
@@ -0,0 +1,35 @@
+using SaferPay.Config;
+
+namespace SaferPay.Tests.Unit;
+
+public class EndpointsTests
+{
+ [Fact]
+ public void ApiVersion_IsCurrent()
+ {
+ SaferPayApiConstants.Version.Should().Be("1.52");
+ }
+
+ [Theory]
+ [InlineData("Payment/v1/Transaction/", "TransactionEndpoint")]
+ [InlineData("Payment/v1/PaymentPage/", "PaymentPageEndpoint")]
+ [InlineData("Payment/v1/Alias/", "AliasEndpoint")]
+ [InlineData("Payment/v1/Batch/", "BatchEndpoint")]
+ [InlineData("Payment/v1/OmniChannel/", "OmniChannelEndpoint")]
+ public void Endpoints_AreStable(string expected, string field)
+ {
+ var f = typeof(SaferPayEndpoints).GetField(field)!;
+ f.GetValue(null).Should().Be(expected);
+ }
+
+ [Fact]
+ public void Methods_HaveExpectedPaths()
+ {
+ SaferPayMethods.TransactionInitialize.Should().Be("Initialize");
+ SaferPayMethods.TransactionAuthorize.Should().Be("Authorize");
+ SaferPayMethods.TransactionCapture.Should().Be("Capture");
+ SaferPayMethods.TransactionRefund.Should().Be("Refund");
+ SaferPayMethods.PaymentPageInitialize.Should().Be("Initialize");
+ SaferPayMethods.PaymentPageAssert.Should().Be("Assert");
+ }
+}
diff --git a/SaferPay.Tests/Unit/InitializeRequestTests.cs b/SaferPay.Tests/Unit/InitializeRequestTests.cs
new file mode 100644
index 0000000..3486c64
--- /dev/null
+++ b/SaferPay.Tests/Unit/InitializeRequestTests.cs
@@ -0,0 +1,42 @@
+using Newtonsoft.Json.Linq;
+using SaferPay.Models.Transaction;
+
+namespace SaferPay.Tests.Unit;
+
+public class InitializeRequestTests
+{
+ [Fact]
+ public void Ctor_PopulatesPaymentAndReturnUrl()
+ {
+ var req = new InitializeRequest("term-1", 12.34m, "CHF", "order-42", "https://shop/return");
+
+ req.TerminalId.Should().Be("term-1");
+ req.Payment.Should().NotBeNull();
+ req.Payment.Amount.Value.Should().Be("1234");
+ req.Payment.Amount.CurrencyCode.Should().Be("CHF");
+ req.Payment.OrderId.Should().Be("order-42");
+
+ req.ReturnUrl.Should().NotBeNull();
+ req.ReturnUrl.Url.Should().Be(new Uri("https://shop/return"));
+ }
+
+ [Fact]
+ public void SetCard_AttachesPaymentMeans()
+ {
+ var req = new InitializeRequest("term", 1m, "EUR", "o", "https://x/r")
+ .SetCard("4242424242424242", 9, 2030, "123", "John");
+
+ req.PaymentMeans.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void Json_OmitsNullChannelOptions()
+ {
+ var req = new InitializeRequest("term", 1m, "CHF", "o", "https://x/r");
+ var jo = JObject.Parse(req.Json());
+
+ jo.ContainsKey("Authentication").Should().BeFalse();
+ jo.ContainsKey("CardForm").Should().BeFalse();
+ jo["TerminalId"]!.Value().Should().Be("term");
+ }
+}
diff --git a/SaferPay.Tests/Unit/JsonExtensionsTests.cs b/SaferPay.Tests/Unit/JsonExtensionsTests.cs
new file mode 100644
index 0000000..d3f3bf0
--- /dev/null
+++ b/SaferPay.Tests/Unit/JsonExtensionsTests.cs
@@ -0,0 +1,37 @@
+using Newtonsoft.Json.Linq;
+using SaferPay.Extensions;
+
+namespace SaferPay.Tests.Unit;
+
+public class JsonExtensionsTests
+{
+ private sealed class Sample
+ {
+ public string? Name { get; set; }
+ public int Value { get; set; }
+ public string? Optional { get; set; }
+ }
+
+ [Fact]
+ public void ToJson_OmitsNullProperties()
+ {
+ var json = new Sample { Name = "n", Value = 7 }.ToJson();
+ var token = JObject.Parse(json);
+
+ token["Name"]!.Value().Should().Be("n");
+ token["Value"]!.Value().Should().Be(7);
+ token.ContainsKey("Optional").Should().BeFalse();
+ }
+
+ [Fact]
+ public void FromJson_RoundtripsObject()
+ {
+ var original = new Sample { Name = "x", Value = 42, Optional = "o" };
+ var roundtrip = original.ToJson().FromJson();
+
+ roundtrip.Should().NotBeNull();
+ roundtrip!.Name.Should().Be("x");
+ roundtrip.Value.Should().Be(42);
+ roundtrip.Optional.Should().Be("o");
+ }
+}
diff --git a/SaferPay.Tests/Unit/PaymentPageRoutingTests.cs b/SaferPay.Tests/Unit/PaymentPageRoutingTests.cs
new file mode 100644
index 0000000..d3a32f4
--- /dev/null
+++ b/SaferPay.Tests/Unit/PaymentPageRoutingTests.cs
@@ -0,0 +1,49 @@
+using SaferPay.Channels;
+using SaferPay.Models.PaymentPage;
+
+namespace SaferPay.Tests.Unit;
+
+public class PaymentPageRoutingTests
+{
+ private const string Base = "Payment/v1/PaymentPage/";
+
+ private static (PaymentPage Channel, RecordingSaferPayClient Client) NewChannel()
+ {
+ var fake = new RecordingSaferPayClient();
+ return (new PaymentPage(fake), fake);
+ }
+
+ [Fact]
+ public async Task InitializeAsync_RoutesTo_Initialize()
+ {
+ var (c, f) = NewChannel();
+ await c.InitializeAsync(new InitializePaymentPageRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Initialize");
+ }
+
+ [Fact]
+ public async Task AssertAsync_WithRequest_RoutesTo_Assert()
+ {
+ var (c, f) = NewChannel();
+ await c.AssertAsync(new AssertRequest("tok"));
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Assert");
+ }
+
+ [Fact]
+ public async Task AssertAsync_WithToken_RoutesTo_Assert()
+ {
+ // String overload constructs the request internally — verify it still
+ // routes to the same endpoint.
+ var (c, f) = NewChannel();
+ await c.AssertAsync("tok");
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Assert");
+ }
+
+ [Fact]
+ public void Assert_WithToken_RoutesTo_Assert()
+ {
+ var (c, f) = NewChannel();
+ c.Assert("tok");
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Assert");
+ }
+}
diff --git a/SaferPay.Tests/Unit/PaymentTests.cs b/SaferPay.Tests/Unit/PaymentTests.cs
new file mode 100644
index 0000000..c254f9d
--- /dev/null
+++ b/SaferPay.Tests/Unit/PaymentTests.cs
@@ -0,0 +1,33 @@
+using SaferPay.Enums;
+using SaferPay.Models.Core;
+
+namespace SaferPay.Tests.Unit;
+
+public class PaymentTests
+{
+ [Fact]
+ public void Ctor_Decimal_BuildsAmountAndOrderId()
+ {
+ var p = new Payment(19.95m, "CHF", "order-1");
+ p.OrderId.Should().Be("order-1");
+ p.Amount.Should().NotBeNull();
+ p.Amount.Value.Should().Be("1995");
+ p.Amount.CurrencyCode.Should().Be("CHF");
+ }
+
+ [Fact]
+ public void Ctor_CurrencyEnum_UppercasesCode()
+ {
+ var p = new Payment(50m, CurrencyCodes.USD, "o");
+ p.Amount.CurrencyCode.Should().Be("USD");
+ p.Amount.Value.Should().Be("5000");
+ }
+
+ [Fact]
+ public void Ctor_AmountAsString_StripsSeparators()
+ {
+ var p = new Payment("1 234.56", "EUR", "o2");
+ p.Amount.Value.Should().Be("123456");
+ p.Amount.CurrencyCode.Should().Be("EUR");
+ }
+}
diff --git a/SaferPay.Tests/Unit/RecordingSaferPayClient.cs b/SaferPay.Tests/Unit/RecordingSaferPayClient.cs
new file mode 100644
index 0000000..cd618a1
--- /dev/null
+++ b/SaferPay.Tests/Unit/RecordingSaferPayClient.cs
@@ -0,0 +1,54 @@
+using SaferPay.Interfaces;
+using SaferPay.Models.Core;
+using SaferPay.Models.Management;
+
+namespace SaferPay.Tests.Unit;
+
+///
+/// Test double for that records every
+/// Send/SendAsync call's path and request type. Used by channel routing
+/// tests to assert that each channel method targets the correct endpoint.
+///
+internal sealed class RecordingSaferPayClient : ISaferPayClient
+{
+ public readonly List<(string Path, Type RequestType)> Calls = new();
+
+ public string CustomerId => "cust";
+ public string TerminalId => "term";
+
+ public ITransaction Transaction => throw new NotImplementedException();
+ public IPaymentPage PaymentPage => throw new NotImplementedException();
+ public ISecureCardData SecureCardData => throw new NotImplementedException();
+ public IBatch Batch => throw new NotImplementedException();
+ public IOmniChannel OmniChannel => throw new NotImplementedException();
+ public IManagementApi ManagementApi => throw new NotImplementedException();
+
+ public TResponse Send(string path, TRequest request)
+ where TRequest : RequestBase
+ where TResponse : ResponseBase
+ {
+ Calls.Add((path, typeof(TRequest)));
+ return Activator.CreateInstance();
+ }
+
+ public Task SendAsync(string path, TRequest request)
+ where TRequest : RequestBase
+ where TResponse : ResponseBase
+ {
+ Calls.Add((path, typeof(TRequest)));
+ return Task.FromResult(Activator.CreateInstance());
+ }
+
+ public TResponse Get(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+ public Task GetAsync(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+ public TResponse Get(string path, TRequest request) where TRequest : RestRequestBase where TResponse : RestResponseBase => throw new NotImplementedException();
+ public Task GetAsync(string path, TRequest request) where TRequest : RestRequestBase where TResponse : RestResponseBase => throw new NotImplementedException();
+ public TResponse Post(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+ public Task PostAsync(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+ public TResponse Post(string path, TRequest request) where TRequest : RestRequestBase where TResponse : RestResponseBase => throw new NotImplementedException();
+ public Task PostAsync(string path, TRequest request) where TRequest : RestRequestBase where TResponse : RestResponseBase => throw new NotImplementedException();
+ public TResponse Delete(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+ public Task DeleteAsync(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+
+ public void Dispose() { }
+}
diff --git a/SaferPay.Tests/Unit/RefundRequestTests.cs b/SaferPay.Tests/Unit/RefundRequestTests.cs
new file mode 100644
index 0000000..ad0f874
--- /dev/null
+++ b/SaferPay.Tests/Unit/RefundRequestTests.cs
@@ -0,0 +1,27 @@
+using SaferPay.Models.Transaction;
+
+namespace SaferPay.Tests.Unit;
+
+public class RefundRequestTests
+{
+ [Fact]
+ public void Ctor_DecimalAmount_BuildsRefundAndCaptureReference()
+ {
+ var req = new RefundRequest("txn-123", 5.50m, "CHF");
+
+ req.CaptureReference.Should().NotBeNull();
+ req.CaptureReference.TransactionId.Should().Be("txn-123");
+ req.Refund.Should().NotBeNull();
+ req.Refund.Amount.Value.Should().Be("550");
+ req.Refund.Amount.CurrencyCode.Should().Be("CHF");
+ }
+
+ [Fact]
+ public void Ctor_StringAmount_StoresValueVerbatim()
+ {
+ var req = new RefundRequest("txn-9", "12345", "EUR");
+ req.Refund.Amount.Value.Should().Be("12345");
+ req.Refund.Amount.CurrencyCode.Should().Be("EUR");
+ req.CaptureReference.TransactionId.Should().Be("txn-9");
+ }
+}
diff --git a/SaferPay.Tests/Unit/RequestBaseTests.cs b/SaferPay.Tests/Unit/RequestBaseTests.cs
new file mode 100644
index 0000000..8bc9fa7
--- /dev/null
+++ b/SaferPay.Tests/Unit/RequestBaseTests.cs
@@ -0,0 +1,29 @@
+using Newtonsoft.Json.Linq;
+using SaferPay.Models.Core;
+
+namespace SaferPay.Tests.Unit;
+
+public class RequestBaseTests
+{
+ private sealed class FakeRequest : RequestBase
+ {
+ public string? Visible { get; set; }
+ public string? Hidden { get; set; }
+ }
+
+ [Fact]
+ public void Json_OmitsNullProperties()
+ {
+ var r = new FakeRequest { Visible = "v" };
+ var jo = JObject.Parse(r.Json());
+ jo["Visible"]!.Value().Should().Be("v");
+ jo.ContainsKey("Hidden").Should().BeFalse();
+ }
+
+ [Fact]
+ public void Json_IsIndentedByDefault()
+ {
+ var json = new FakeRequest { Visible = "v" }.Json();
+ json.Should().Contain(Environment.NewLine);
+ }
+}
diff --git a/SaferPay.Tests/Unit/ResponseBaseTests.cs b/SaferPay.Tests/Unit/ResponseBaseTests.cs
new file mode 100644
index 0000000..c21d000
--- /dev/null
+++ b/SaferPay.Tests/Unit/ResponseBaseTests.cs
@@ -0,0 +1,31 @@
+using SaferPay.Enums;
+using SaferPay.Models.Core;
+
+namespace SaferPay.Tests.Unit;
+
+public class ResponseBaseTests
+{
+ private sealed class FakeResponse : ResponseBase { }
+
+ [Theory]
+ [InlineData(ResponseStatus.SUCCESS, true)]
+ [InlineData(ResponseStatus.ERROR, false)]
+ [InlineData(ResponseStatus.NONE, false)]
+ public void IsSuccess_ReflectsStatus(ResponseStatus status, bool expected)
+ {
+ new FakeResponse { ResponseStatus = status }.IsSuccess.Should().Be(expected);
+ }
+
+ [Fact]
+ public void Json_RoundtripsAnErrorPayload()
+ {
+ var r = new FakeResponse
+ {
+ ResponseStatus = ResponseStatus.ERROR,
+ Error = new ErrorResponse { ErrorMessage = "oops" },
+ };
+
+ var json = r.Json();
+ json.Should().Contain("oops");
+ }
+}
diff --git a/SaferPay.Tests/Unit/ReturnUrlTests.cs b/SaferPay.Tests/Unit/ReturnUrlTests.cs
new file mode 100644
index 0000000..d27cc0b
--- /dev/null
+++ b/SaferPay.Tests/Unit/ReturnUrlTests.cs
@@ -0,0 +1,27 @@
+using SaferPay.Models.Core;
+
+namespace SaferPay.Tests.Unit;
+
+public class ReturnUrlTests
+{
+ [Fact]
+ public void ImplicitConversion_FromString_BuildsUri()
+ {
+ ReturnUrl r = "https://shop/return";
+ r.Url.Should().Be(new Uri("https://shop/return"));
+ }
+
+ [Fact]
+ public void ExplicitConversion_ToString_RoundTrips()
+ {
+ var r = new ReturnUrl("https://shop/return");
+ ((string)r).Should().Be("https://shop/return");
+ }
+
+ [Fact]
+ public void Ctor_FromUri_PreservesValue()
+ {
+ var uri = new Uri("https://shop/x");
+ new ReturnUrl(uri).Url.Should().BeSameAs(uri);
+ }
+}
diff --git a/SaferPay.Tests/Unit/SaferPayClientExtensionsTests.cs b/SaferPay.Tests/Unit/SaferPayClientExtensionsTests.cs
new file mode 100644
index 0000000..623432b
--- /dev/null
+++ b/SaferPay.Tests/Unit/SaferPayClientExtensionsTests.cs
@@ -0,0 +1,292 @@
+using SaferPay.Interfaces;
+using SaferPay.Models.Core;
+using SaferPay.Models.Management;
+using SaferPay.Models.PaymentPage;
+using SaferPay.Models.Transaction;
+
+namespace SaferPay.Tests.Unit;
+
+public class SaferPayClientExtensionsTests
+{
+ [Fact]
+ public void InitializeTransaction_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new InitializeRequest();
+
+ _ = fake.InitializeTransaction(req);
+
+ fake.Transaction.LastInitialize.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public async Task InitializeTransactionAsync_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new InitializeRequest();
+
+ _ = await fake.InitializeTransactionAsync(req);
+
+ fake.Transaction.LastInitialize.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public void Authorize_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new AuthorizeRequest();
+
+ _ = fake.Authorize(req);
+
+ fake.Transaction.LastAuthorize.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public async Task AuthorizeAsync_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new AuthorizeRequest();
+
+ _ = await fake.AuthorizeAsync(req);
+
+ fake.Transaction.LastAuthorize.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public void Capture_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new CaptureRequest("txn");
+
+ _ = fake.Capture(req);
+
+ fake.Transaction.LastCapture.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public async Task CaptureAsync_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new CaptureRequest("txn");
+
+ _ = await fake.CaptureAsync(req);
+
+ fake.Transaction.LastCapture.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public void Cancel_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new CancelRequest();
+
+ _ = fake.Cancel(req);
+
+ fake.Transaction.LastCancel.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public async Task CancelAsync_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new CancelRequest();
+
+ _ = await fake.CancelAsync(req);
+
+ fake.Transaction.LastCancel.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public void Refund_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new RefundRequest("txn", 1m, "CHF");
+
+ _ = fake.Refund(req);
+
+ fake.Transaction.LastRefund.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public async Task RefundAsync_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new RefundRequest("txn", 1m, "CHF");
+
+ _ = await fake.RefundAsync(req);
+
+ fake.Transaction.LastRefund.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public void Inquiry_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new InquireRequest();
+
+ _ = fake.Inquiry(req);
+
+ fake.Transaction.LastInquire.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public async Task InquiryAsync_DelegatesToTransactionChannel()
+ {
+ var fake = new FakeClient();
+ var req = new InquireRequest();
+
+ _ = await fake.InquiryAsync(req);
+
+ fake.Transaction.LastInquire.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public void InitializePaymentPage_DelegatesToPaymentPageChannel()
+ {
+ var fake = new FakeClient();
+ var req = new InitializePaymentPageRequest();
+
+ _ = fake.InitializePaymentPage(req);
+
+ fake.PaymentPage.LastInitialize.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public async Task InitializePaymentPageAsync_DelegatesToPaymentPageChannel()
+ {
+ var fake = new FakeClient();
+ var req = new InitializePaymentPageRequest();
+
+ _ = await fake.InitializePaymentPageAsync(req);
+
+ fake.PaymentPage.LastInitialize.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public void AssertPaymentPage_DelegatesToPaymentPageChannel()
+ {
+ var fake = new FakeClient();
+ var req = new AssertRequest("tok");
+
+ _ = fake.AssertPaymentPage(req);
+
+ fake.PaymentPage.LastAssert.Should().BeSameAs(req);
+ }
+
+ [Fact]
+ public async Task AssertPaymentPageAsync_DelegatesToPaymentPageChannel()
+ {
+ var fake = new FakeClient();
+ var req = new AssertRequest("tok");
+
+ _ = await fake.AssertPaymentPageAsync(req);
+
+ fake.PaymentPage.LastAssert.Should().BeSameAs(req);
+ }
+
+ private sealed class FakeClient : ISaferPayClient
+ {
+ public FakeTransaction Transaction { get; } = new();
+ public FakePaymentPage PaymentPage { get; } = new();
+
+ ITransaction ISaferPayClient.Transaction => Transaction;
+ IPaymentPage ISaferPayClient.PaymentPage => PaymentPage;
+ ISecureCardData ISaferPayClient.SecureCardData => throw new NotImplementedException();
+ IBatch ISaferPayClient.Batch => throw new NotImplementedException();
+ IOmniChannel ISaferPayClient.OmniChannel => throw new NotImplementedException();
+ IManagementApi ISaferPayClient.ManagementApi => throw new NotImplementedException();
+
+ public string CustomerId => "c";
+ public string TerminalId => "t";
+
+ public TResponse Send(string path, TRequest request)
+ where TRequest : RequestBase
+ where TResponse : ResponseBase
+ => throw new NotImplementedException();
+
+ public Task SendAsync(string path, TRequest request)
+ where TRequest : RequestBase
+ where TResponse : ResponseBase
+ => throw new NotImplementedException();
+
+ public TResponse Get(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+ public Task GetAsync(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+ public TResponse Get(string path, TRequest request) where TRequest : RestRequestBase where TResponse : RestResponseBase => throw new NotImplementedException();
+ public Task GetAsync(string path, TRequest request) where TRequest : RestRequestBase where TResponse : RestResponseBase => throw new NotImplementedException();
+ public TResponse Post(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+ public Task PostAsync(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+ public TResponse Post(string path, TRequest request) where TRequest : RestRequestBase where TResponse : RestResponseBase => throw new NotImplementedException();
+ public Task PostAsync(string path, TRequest request) where TRequest : RestRequestBase where TResponse : RestResponseBase => throw new NotImplementedException();
+ public TResponse Delete(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+ public Task DeleteAsync(string path) where TResponse : RestResponseBase => throw new NotImplementedException();
+
+ public void Dispose() { }
+ }
+
+ private sealed class FakeTransaction : ITransaction
+ {
+ public InitializeRequest? LastInitialize;
+ public AuthorizeRequest? LastAuthorize;
+ public CaptureRequest? LastCapture;
+ public CancelRequest? LastCancel;
+ public RefundRequest? LastRefund;
+ public InquireRequest? LastInquire;
+
+ public InitializeResponse Initialize(InitializeRequest request) { LastInitialize = request; return new(); }
+ public Task InitializeAsync(InitializeRequest request) { LastInitialize = request; return Task.FromResult(new InitializeResponse()); }
+
+ public AuthorizeResponse Authorize(AuthorizeRequest request) { LastAuthorize = request; return new(); }
+ public Task AuthorizeAsync(AuthorizeRequest request) { LastAuthorize = request; return Task.FromResult(new AuthorizeResponse()); }
+
+ public CaptureResponse Capture(CaptureRequest request) { LastCapture = request; return new(); }
+ public Task CaptureAsync(CaptureRequest request) { LastCapture = request; return Task.FromResult(new CaptureResponse()); }
+
+ public CancelResponse Cancel(CancelRequest request) { LastCancel = request; return new(); }
+ public Task CancelAsync(CancelRequest request) { LastCancel = request; return Task.FromResult(new CancelResponse()); }
+
+ public RefundResponse Refund(RefundRequest request) { LastRefund = request; return new(); }
+ public Task RefundAsync(RefundRequest request) { LastRefund = request; return Task.FromResult(new RefundResponse()); }
+
+ public InquireResponse Inquire(InquireRequest request) { LastInquire = request; return new(); }
+ public Task InquireAsync(InquireRequest request) { LastInquire = request; return Task.FromResult(new InquireResponse()); }
+
+ // Unused members
+ public AuthorizeDirectResponse AuthorizeDirect(AuthorizeDirectRequest request) => new();
+ public Task AuthorizeDirectAsync(AuthorizeDirectRequest request) => Task.FromResult(new AuthorizeDirectResponse());
+ public AuthorizeReferencedResponse AuthorizeReferenced(AuthorizeReferencedRequest request) => new();
+ public Task AuthorizeReferencedAsync(AuthorizeReferencedRequest request) => Task.FromResult(new AuthorizeReferencedResponse());
+ public MultipartCaptureResponse MultipartCapture(MultipartCaptureRequest request) => new();
+ public Task MultipartCaptureAsync(MultipartCaptureRequest request) => Task.FromResult(new MultipartCaptureResponse());
+ public AssertCaptureResponse AssertCapture(AssertCaptureRequest request) => new();
+ public Task AssertCaptureAsync(AssertCaptureRequest request) => Task.FromResult(new AssertCaptureResponse());
+ public MultipartFinalizeResponse MultipartFinalize(MultipartFinalizeRequest request) => new();
+ public Task MultipartFinalizeAsync(MultipartFinalizeRequest request) => Task.FromResult(new MultipartFinalizeResponse());
+ public AssertRefundResponse AssertRefund(AssertRefundRequest request) => new();
+ public Task AssertRefundAsync(AssertRefundRequest request) => Task.FromResult(new AssertRefundResponse());
+ public RefundDirectResponse RefundDirect(RefundDirectRequest request) => new();
+ public Task RefundDirectAsync(RefundDirectRequest request) => Task.FromResult(new RefundDirectResponse());
+ public RedirectPaymentResponse RedirectPayment(RedirectPaymentRequest request) => new();
+ public Task RedirectPaymentAsync(RedirectPaymentRequest request) => Task.FromResult(new RedirectPaymentResponse());
+ public AssertRedirectPaymentResponse AssertRedirectPayment(AssertRedirectPaymentRequest request) => new();
+ public Task AssertRedirectPaymentAsync(AssertRedirectPaymentRequest request) => Task.FromResult(new AssertRedirectPaymentResponse());
+ public AlternativePaymentResponse AlternativePayment(AlternativePaymentRequest request) => new();
+ public Task AlternativePaymentAsync(AlternativePaymentRequest request) => Task.FromResult(new AlternativePaymentResponse());
+ public QueryAlternativePaymentResponse QueryAlternativePayment(QueryAlternativePaymentRequest request) => new();
+ public Task QueryAlternativePaymentAsync(QueryAlternativePaymentRequest request) => Task.FromResult(new QueryAlternativePaymentResponse());
+ public DccInquiryResponse DccInquire(DccInquiryRequest request) => new();
+ public Task DccInquiryAsync(DccInquiryRequest request) => Task.FromResult(new DccInquiryResponse());
+ }
+
+ private sealed class FakePaymentPage : IPaymentPage
+ {
+ public InitializePaymentPageRequest? LastInitialize;
+ public AssertRequest? LastAssert;
+
+ public InitializePaymentPageResponse Initialize(InitializePaymentPageRequest request) { LastInitialize = request; return new(); }
+ public Task InitializeAsync(InitializePaymentPageRequest request) { LastInitialize = request; return Task.FromResult(new InitializePaymentPageResponse()); }
+ public AssertResponse Assert(AssertRequest request) { LastAssert = request; return new(); }
+ public AssertResponse Assert(string token) { LastAssert = new AssertRequest(token); return new(); }
+ public Task AssertAsync(AssertRequest request) { LastAssert = request; return Task.FromResult(new AssertResponse()); }
+ public Task AssertAsync(string token) { LastAssert = new AssertRequest(token); return Task.FromResult(new AssertResponse()); }
+ }
+}
diff --git a/SaferPay.Tests/Unit/SaferPayClientTests.cs b/SaferPay.Tests/Unit/SaferPayClientTests.cs
new file mode 100644
index 0000000..d3a4223
--- /dev/null
+++ b/SaferPay.Tests/Unit/SaferPayClientTests.cs
@@ -0,0 +1,57 @@
+using SaferPay.Interfaces;
+using SaferPay.Models.Core;
+
+namespace SaferPay.Tests.Unit;
+
+public class SaferPayClientTests
+{
+ private static SaferPayClient NewClient() =>
+ new("cust-id", "term-id", "user", "pwd", sandBox: true);
+
+ [Fact]
+ public void Constructor_ExposesCustomerAndTerminal()
+ {
+ var c = NewClient();
+ c.CustomerId.Should().Be("cust-id");
+ c.TerminalId.Should().Be("term-id");
+ }
+
+ [Fact]
+ public void Channels_AreNotNull()
+ {
+ var c = NewClient();
+ c.Transaction.Should().NotBeNull();
+ c.PaymentPage.Should().NotBeNull();
+ c.SecureCardData.Should().NotBeNull();
+ c.Batch.Should().NotBeNull();
+ c.OmniChannel.Should().NotBeNull();
+ c.ManagementApi.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void SendAsync_Throws_OnNullRequest()
+ {
+ var c = NewClient();
+ Func act = () => c.SendAsync("any/path", null!);
+ act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public void Send_Throws_OnNullRequest()
+ {
+ var c = NewClient();
+ Action act = () => c.Send("any/path", null!);
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Dispose_DoesNotThrow()
+ {
+ using var c = NewClient();
+ Action act = () => c.Dispose();
+ act.Should().NotThrow();
+ }
+
+ private sealed class DummyRequest : RequestBase { }
+ private sealed class DummyResponse : ResponseBase { }
+}
diff --git a/SaferPay.Tests/Unit/SaferPayExceptionTests.cs b/SaferPay.Tests/Unit/SaferPayExceptionTests.cs
new file mode 100644
index 0000000..f191309
--- /dev/null
+++ b/SaferPay.Tests/Unit/SaferPayExceptionTests.cs
@@ -0,0 +1,39 @@
+using System.Net;
+using SaferPay.Enums;
+using SaferPay.Exceptions;
+using SaferPay.Models.Core;
+
+namespace SaferPay.Tests.Unit;
+
+public class SaferPayExceptionTests
+{
+ [Fact]
+ public void Ctor_BuildsRichMessage_WithStatusAndError()
+ {
+ var error = new ErrorResponse
+ {
+ ErrorName = ErrorNames.VALIDATION_FAILED,
+ ErrorMessage = "bad input",
+ ErrorDetail = new[] { "field=Amount", "field=TerminalId" },
+ };
+
+ var ex = new SaferPayException(HttpStatusCode.BadRequest, error);
+
+ ex.HttpStatusCode.Should().Be(HttpStatusCode.BadRequest);
+ ex.ErrorResponse.Should().BeSameAs(error);
+ ex.Message.Should().Contain("BadRequest");
+ ex.Message.Should().Contain("bad input");
+ ex.Message.Should().Contain("field=Amount");
+ ex.Message.Should().Contain("field=TerminalId");
+ }
+
+ [Fact]
+ public void Ctor_HandlesNullErrorDetail()
+ {
+ var ex = new SaferPayException(HttpStatusCode.InternalServerError,
+ new ErrorResponse { ErrorMessage = "boom" });
+
+ ex.Message.Should().Contain("InternalServerError");
+ ex.Message.Should().Contain("boom");
+ }
+}
diff --git a/SaferPay.Tests/Unit/SaferPaySettingsTests.cs b/SaferPay.Tests/Unit/SaferPaySettingsTests.cs
new file mode 100644
index 0000000..651bcb8
--- /dev/null
+++ b/SaferPay.Tests/Unit/SaferPaySettingsTests.cs
@@ -0,0 +1,44 @@
+using SaferPay.Models.Core;
+
+namespace SaferPay.Tests.Unit;
+
+public class SaferPaySettingsTests
+{
+ [Fact]
+ public void Ctor_WithArguments_AssignsAllFields()
+ {
+ var s = new SaferPaySettings("cust", "term", "user", "pwd", sandBox: true);
+
+ s.CustomerId.Should().Be("cust");
+ s.TerminalId.Should().Be("term");
+ s.Username.Should().Be("user");
+ s.Password.Should().Be("pwd");
+ s.SandBox.Should().BeTrue();
+ s.ThrowExceptionOnFail.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Default_Ctor_LeavesProductionMode()
+ {
+ new SaferPaySettings().SandBox.Should().BeFalse();
+ }
+
+ [Fact]
+ public void BaseUri_Switches_OnSandbox()
+ {
+ var prod = new SaferPaySettings { SandBox = false };
+ var test = new SaferPaySettings { SandBox = true };
+
+ prod.BaseUri.Should().Be(new Uri("https://www.saferpay.com/api/"));
+ test.BaseUri.Should().Be(new Uri("https://test.saferpay.com/api/"));
+ }
+
+ [Fact]
+ public void BaseRestUri_Switches_OnSandbox()
+ {
+ new SaferPaySettings { SandBox = false }.BaseRestUri
+ .Should().Be(new Uri("https://www.saferpay.com/"));
+ new SaferPaySettings { SandBox = true }.BaseRestUri
+ .Should().Be(new Uri("https://test.saferpay.com/"));
+ }
+}
diff --git a/SaferPay.Tests/Unit/SecureCardDataRoutingTests.cs b/SaferPay.Tests/Unit/SecureCardDataRoutingTests.cs
new file mode 100644
index 0000000..e7989a2
--- /dev/null
+++ b/SaferPay.Tests/Unit/SecureCardDataRoutingTests.cs
@@ -0,0 +1,63 @@
+using SaferPay.Channels;
+using SaferPay.Models.SecureData;
+
+namespace SaferPay.Tests.Unit;
+
+public class SecureCardDataRoutingTests
+{
+ private const string Base = "Payment/v1/Alias/";
+
+ private static (SecureCardData Channel, RecordingSaferPayClient Client) NewChannel()
+ {
+ var fake = new RecordingSaferPayClient();
+ return (new SecureCardData(fake), fake);
+ }
+
+ [Fact]
+ public async Task AliasInsertAsync_RoutesTo_Insert()
+ {
+ var (c, f) = NewChannel();
+ await c.AliasInsertAsync(new AliasInsertRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Insert");
+ }
+
+ [Fact]
+ public async Task AssertInsertAsync_RoutesTo_AssertInsert()
+ {
+ var (c, f) = NewChannel();
+ await c.AssertInsertAsync(new AssertInsertRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "AssertInsert");
+ }
+
+ [Fact]
+ public async Task InsertDirectAsync_RoutesTo_InsertDirect()
+ {
+ var (c, f) = NewChannel();
+ await c.InsertDirectAsync(new InsertDirectRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "InsertDirect");
+ }
+
+ [Fact]
+ public async Task AliasUpdateAsync_RoutesTo_Update()
+ {
+ var (c, f) = NewChannel();
+ await c.AliasUpdateAsync(new AliasUpdateRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Update");
+ }
+
+ [Fact]
+ public async Task AliasDeleteAsync_RoutesTo_Delete()
+ {
+ var (c, f) = NewChannel();
+ await c.AliasDeleteAsync(new AliasDeleteRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Delete");
+ }
+
+ [Fact]
+ public async Task AliasInquireAsync_RoutesTo_Inquire()
+ {
+ var (c, f) = NewChannel();
+ await c.AliasInquireAsync(new AliasInquireRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Inquire");
+ }
+}
diff --git a/SaferPay.Tests/Unit/TransactionRoutingTests.cs b/SaferPay.Tests/Unit/TransactionRoutingTests.cs
new file mode 100644
index 0000000..3b4c8b7
--- /dev/null
+++ b/SaferPay.Tests/Unit/TransactionRoutingTests.cs
@@ -0,0 +1,177 @@
+using SaferPay.Channels;
+using SaferPay.Models.Transaction;
+
+namespace SaferPay.Tests.Unit;
+
+///
+/// Verifies that every method routes to the
+/// expected SaferPay endpoint+path. Catches regressions where a method
+/// is accidentally wired to the wrong constant in SaferPayMethods.
+///
+public class TransactionRoutingTests
+{
+ private const string Base = "Payment/v1/Transaction/";
+
+ private static (Transaction Channel, RecordingSaferPayClient Client) NewChannel()
+ {
+ var fake = new RecordingSaferPayClient();
+ return (new Transaction(fake), fake);
+ }
+
+ [Fact]
+ public async Task InitializeAsync_RoutesTo_Initialize()
+ {
+ var (c, f) = NewChannel();
+ await c.InitializeAsync(new InitializeRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Initialize");
+ }
+
+ [Fact]
+ public void Initialize_RoutesTo_Initialize()
+ {
+ var (c, f) = NewChannel();
+ c.Initialize(new InitializeRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Initialize");
+ }
+
+ [Fact]
+ public async Task AuthorizeAsync_RoutesTo_Authorize()
+ {
+ var (c, f) = NewChannel();
+ await c.AuthorizeAsync(new AuthorizeRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Authorize");
+ }
+
+ [Fact]
+ public async Task AuthorizeDirectAsync_RoutesTo_AuthorizeDirect()
+ {
+ var (c, f) = NewChannel();
+ await c.AuthorizeDirectAsync(new AuthorizeDirectRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "AuthorizeDirect");
+ }
+
+ [Fact]
+ public async Task AuthorizeReferencedAsync_RoutesTo_AuthorizeReferenced()
+ {
+ var (c, f) = NewChannel();
+ await c.AuthorizeReferencedAsync(new AuthorizeReferencedRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "AuthorizeReferenced");
+ }
+
+ [Fact]
+ public async Task CaptureAsync_RoutesTo_Capture()
+ {
+ var (c, f) = NewChannel();
+ await c.CaptureAsync(new CaptureRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Capture");
+ }
+
+ [Fact]
+ public async Task MultipartCaptureAsync_RoutesTo_MultipartCapture()
+ {
+ var (c, f) = NewChannel();
+ await c.MultipartCaptureAsync(new MultipartCaptureRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "MultipartCapture");
+ }
+
+ [Fact]
+ public async Task AssertCaptureAsync_RoutesTo_AssertCapture()
+ {
+ var (c, f) = NewChannel();
+ await c.AssertCaptureAsync(new AssertCaptureRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "AssertCapture");
+ }
+
+ [Fact]
+ public async Task MultipartFinalizeAsync_RoutesTo_MultipartFinalize()
+ {
+ var (c, f) = NewChannel();
+ await c.MultipartFinalizeAsync(new MultipartFinalizeRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "MultipartFinalize");
+ }
+
+ [Fact]
+ public async Task RefundAsync_RoutesTo_Refund()
+ {
+ var (c, f) = NewChannel();
+ await c.RefundAsync(new RefundRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Refund");
+ }
+
+ [Fact]
+ public async Task AssertRefundAsync_RoutesTo_AssertRefund()
+ {
+ var (c, f) = NewChannel();
+ await c.AssertRefundAsync(new AssertRefundRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "AssertRefund");
+ }
+
+ [Fact]
+ public async Task RefundDirectAsync_RoutesTo_RefundDirect()
+ {
+ var (c, f) = NewChannel();
+ await c.RefundDirectAsync(new RefundDirectRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "RefundDirect");
+ }
+
+ [Fact]
+ public async Task CancelAsync_RoutesTo_Cancel()
+ {
+ var (c, f) = NewChannel();
+ await c.CancelAsync(new CancelRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Cancel");
+ }
+
+ [Fact]
+ public async Task InquireAsync_RoutesTo_Inquire()
+ {
+ var (c, f) = NewChannel();
+ await c.InquireAsync(new InquireRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "Inquire");
+ }
+
+ [Fact]
+ public async Task DccInquiryAsync_RoutesTo_DccInquiry()
+ {
+ var (c, f) = NewChannel();
+ await c.DccInquiryAsync(new DccInquiryRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "DccInquiry");
+ }
+
+ [Fact]
+ public async Task AlternativePaymentAsync_RoutesTo_AlternativePayment()
+ {
+ var (c, f) = NewChannel();
+ await c.AlternativePaymentAsync(new AlternativePaymentRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "AlternativePayment");
+ }
+
+ [Fact]
+ public async Task QueryAlternativePaymentAsync_RoutesTo_QueryAlternativePayment()
+ {
+ var (c, f) = NewChannel();
+ await c.QueryAlternativePaymentAsync(new QueryAlternativePaymentRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "QueryAlternativePayment");
+ }
+
+ // RedirectPayment / AssertRedirectPayment are [Obsolete]. Still routed,
+ // still worth pinning so a future cleanup of the constants doesn't
+ // silently break apps that depend on them.
+#pragma warning disable CS0618
+ [Fact]
+ public async Task RedirectPaymentAsync_RoutesTo_RedirectPayment()
+ {
+ var (c, f) = NewChannel();
+ await c.RedirectPaymentAsync(new RedirectPaymentRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "RedirectPayment");
+ }
+
+ [Fact]
+ public async Task AssertRedirectPaymentAsync_RoutesTo_AssertRedirectPayment()
+ {
+ var (c, f) = NewChannel();
+ await c.AssertRedirectPaymentAsync(new AssertRedirectPaymentRequest());
+ f.Calls.Should().ContainSingle().Which.Path.Should().Be(Base + "AssertRedirectPayment");
+ }
+#pragma warning restore CS0618
+}
diff --git a/SaferPay.csproj b/SaferPay.csproj
index 14bf133..2234c16 100644
--- a/SaferPay.csproj
+++ b/SaferPay.csproj
@@ -32,6 +32,9 @@
+
+
+
diff --git a/SaferPay.sln b/SaferPay.sln
index da8ec60..2e18f63 100644
--- a/SaferPay.sln
+++ b/SaferPay.sln
@@ -7,20 +7,54 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SaferPay", "SaferPay.csproj
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SaferPay.Test", "SaferPay.Test\SaferPay.Test.csproj", "{4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SaferPay.Tests", "SaferPay.Tests\SaferPay.Tests.csproj", "{C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{78E12AF7-4FE0-4970-A045-31CB27B32291}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{78E12AF7-4FE0-4970-A045-31CB27B32291}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {78E12AF7-4FE0-4970-A045-31CB27B32291}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {78E12AF7-4FE0-4970-A045-31CB27B32291}.Debug|x64.Build.0 = Debug|Any CPU
+ {78E12AF7-4FE0-4970-A045-31CB27B32291}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {78E12AF7-4FE0-4970-A045-31CB27B32291}.Debug|x86.Build.0 = Debug|Any CPU
{78E12AF7-4FE0-4970-A045-31CB27B32291}.Release|Any CPU.ActiveCfg = Release|Any CPU
{78E12AF7-4FE0-4970-A045-31CB27B32291}.Release|Any CPU.Build.0 = Release|Any CPU
+ {78E12AF7-4FE0-4970-A045-31CB27B32291}.Release|x64.ActiveCfg = Release|Any CPU
+ {78E12AF7-4FE0-4970-A045-31CB27B32291}.Release|x64.Build.0 = Release|Any CPU
+ {78E12AF7-4FE0-4970-A045-31CB27B32291}.Release|x86.ActiveCfg = Release|Any CPU
+ {78E12AF7-4FE0-4970-A045-31CB27B32291}.Release|x86.Build.0 = Release|Any CPU
{4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Debug|x64.Build.0 = Debug|Any CPU
+ {4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Debug|x86.Build.0 = Debug|Any CPU
{4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Release|x64.ActiveCfg = Release|Any CPU
+ {4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Release|x64.Build.0 = Release|Any CPU
+ {4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Release|x86.ActiveCfg = Release|Any CPU
+ {4417D021-5D85-4B22-A9F5-C4E42B1F4ECE}.Release|x86.Build.0 = Release|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Debug|x64.Build.0 = Debug|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Debug|x86.Build.0 = Debug|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Release|x64.ActiveCfg = Release|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Release|x64.Build.0 = Release|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Release|x86.ActiveCfg = Release|Any CPU
+ {C9F4D204-6EF3-45B9-BFE2-113A3C882A4F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE