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