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
58 changes: 55 additions & 3 deletions .github/workflows/dotnet-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion Models/Core/RegisterAlias.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public class RegisterAlias
/// <i>Range: inclusive between 1 and 1600<br/>
/// Example: 1000</i>
/// </summary>
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; }

}
11 changes: 8 additions & 3 deletions Models/SecureData/AliasInsertRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,23 @@ public class AliasInsertRequest : RequestBase
/// <summary>
/// Language used for displaying forms.
/// </summary>
public LanguageCodes LanguageCode { get; set; }
// Nullable: see Check below.
public LanguageCodes? LanguageCode { get; set; }

/// <summary>
/// Parameters for checking the means of payment before registering.
/// </summary>
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; }

/// <summary>
/// Used to restrict the means of payment which are available to the payer<br/>
/// <i>AMEX, BONUS, DINERS, DIRECTDEBIT, JCB, MAESTRO, MASTERCARD, MYONE, VISA</i>
/// </summary>
public AliasPaymentMethods PaymentMethods { get; set; }
// Nullable: see Check above.
public AliasPaymentMethods? PaymentMethods { get; set; }

/// <summary>
/// Options for card data entry form (if applicable)
Expand Down
2 changes: 2 additions & 0 deletions SaferPay.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
global using Xunit;
global using FluentAssertions;
52 changes: 52 additions & 0 deletions SaferPay.Tests/Integration/BatchAndOmniChannelSandboxTests.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
76 changes: 76 additions & 0 deletions SaferPay.Tests/Integration/ManagementApiSandboxTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
namespace SaferPay.Tests.Integration;

/// <summary>
/// Exercises the REST pipeline in <see cref="SaferPayClient"/>
/// (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.
/// </summary>
[Trait("Category", "Integration")]
public class ManagementApiSandboxTests
{
[SandboxFact]
public async Task TerminalGetTerminal_ReturnsTerminalInfo()
{
// Exercises Get<TResponse>(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<TResponse>(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<TResponse>:
// - "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<TResponse> test would naturally go here, but the only
// ManagementApi method that wires DeleteAsync — SaferpayFieldsAccessToken
// DeleteAccessTokenAsync — declares Task<RestResponseBase>, and
// RestResponseBase is abstract. The SDK's error path runs
// Activator.CreateInstance<TResponse>() 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).
}
148 changes: 148 additions & 0 deletions SaferPay.Tests/Integration/PaymentPageSandboxTests.cs
Original file line number Diff line number Diff line change
@@ -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<SaferPayException>()).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();
}
}
Loading
Loading