From 75a73d1c271fe0f211bfbb7cf7d730a13e4f766c Mon Sep 17 00:00:00 2001 From: Anthony Timmers Date: Thu, 27 Nov 2025 16:13:35 +0100 Subject: [PATCH 1/4] Add missing verifications for CSC, OIDC, and Onfido --- .../Rest/DataObjects/CscVerification.cs | 41 +++++++++++++++++++ .../DataObjects/EherkenningVerification.cs | 21 ++++++++++ .../Rest/DataObjects/OidcVerification.cs | 19 +++++++++ .../Rest/DataObjects/OnfidoVerification.cs | 37 +++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 src/SignhostAPIClient/Rest/DataObjects/CscVerification.cs create mode 100644 src/SignhostAPIClient/Rest/DataObjects/EherkenningVerification.cs create mode 100644 src/SignhostAPIClient/Rest/DataObjects/OidcVerification.cs create mode 100644 src/SignhostAPIClient/Rest/DataObjects/OnfidoVerification.cs diff --git a/src/SignhostAPIClient/Rest/DataObjects/CscVerification.cs b/src/SignhostAPIClient/Rest/DataObjects/CscVerification.cs new file mode 100644 index 0000000..109074e --- /dev/null +++ b/src/SignhostAPIClient/Rest/DataObjects/CscVerification.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +namespace Signhost.APIClient.Rest.DataObjects +{ + /// + /// Cloud Signature Consortium (CSC) verification. + /// + public class CscVerification + : IVerification + { + /// + /// Gets the . + /// + public string Type => "CSC Qualified"; + + /// + /// Gets or sets the provider identifier. + /// + public string Provider { get; set; } + + /// + /// Gets or sets the certificate issuer. + /// + public string Issuer { get; set; } + + /// + /// Gets or sets the certificate subject. + /// + public string Subject { get; set; } + + /// + /// Gets or sets the certificate thumbprint. + /// + public string Thumbprint { get; set; } + + /// + /// Gets or sets additional user data. + /// + public Dictionary AdditionalUserData { get; set; } + } +} diff --git a/src/SignhostAPIClient/Rest/DataObjects/EherkenningVerification.cs b/src/SignhostAPIClient/Rest/DataObjects/EherkenningVerification.cs new file mode 100644 index 0000000..adee19d --- /dev/null +++ b/src/SignhostAPIClient/Rest/DataObjects/EherkenningVerification.cs @@ -0,0 +1,21 @@ +namespace Signhost.APIClient.Rest.DataObjects +{ + public class EherkenningVerification + : IVerification + { + /// + /// Gets the . + /// + public string Type => "eHerkenning"; + + /// + /// Gets or sets the Uid. + /// + public string Uid { get; set; } + + /// + /// Gets or sets the entity concern ID / KVK number. + /// + public string EntityConcernIdKvkNr { get; set; } + } +} diff --git a/src/SignhostAPIClient/Rest/DataObjects/OidcVerification.cs b/src/SignhostAPIClient/Rest/DataObjects/OidcVerification.cs new file mode 100644 index 0000000..208b012 --- /dev/null +++ b/src/SignhostAPIClient/Rest/DataObjects/OidcVerification.cs @@ -0,0 +1,19 @@ +namespace Signhost.APIClient.Rest.DataObjects +{ + /// + /// OpenID Connect identification. + /// + public class OidcVerification + : IVerification + { + /// + /// Gets the . + /// + public string Type => "OpenID Providers"; + + /// + /// Gets or sets the OIDC provider name. + /// + public string ProviderName { get; set; } + } +} diff --git a/src/SignhostAPIClient/Rest/DataObjects/OnfidoVerification.cs b/src/SignhostAPIClient/Rest/DataObjects/OnfidoVerification.cs new file mode 100644 index 0000000..7a9d166 --- /dev/null +++ b/src/SignhostAPIClient/Rest/DataObjects/OnfidoVerification.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace Signhost.APIClient.Rest.DataObjects +{ + /// + /// Onfido identity verification. + /// + public class OnfidoVerification + : IVerification + { + /// + /// Gets the . + /// + public string Type => "Onfido"; + + /// + /// Gets or sets the Onfido workflow identifier. + /// + public Guid? WorkflowId { get; set; } + + /// + /// Gets or sets the Onfido workflow run identifier. + /// + public Guid? WorkflowRunId { get; set; } + + /// + /// Gets or sets the Onfido API version. + /// + public int? Version { get; set; } + + /// + /// Gets or sets raw Onfido attributes (availability not guaranteed). + /// + public Dictionary Attributes { get; set; } + } +} From 23a5c3a034cb77fdbedbcbdd9deed00434a9ec3e Mon Sep 17 00:00:00 2001 From: Anthony Timmers Date: Thu, 27 Nov 2025 16:41:00 +0100 Subject: [PATCH 2/4] Add ResponseBody property to exceptions --- src/SignhostAPIClient/Rest/ApiResponse.cs | 9 +++- .../BadAuthorizationException.cs | 1 + ...pResponseMessageErrorHandlingExtensions.cs | 45 +++++++++++++------ .../Rest/ErrorHandling/SignhostException.cs | 1 + .../SignhostRestApiClientException.cs | 5 +++ 5 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/SignhostAPIClient/Rest/ApiResponse.cs b/src/SignhostAPIClient/Rest/ApiResponse.cs index f6c6c12..880dcd3 100644 --- a/src/SignhostAPIClient/Rest/ApiResponse.cs +++ b/src/SignhostAPIClient/Rest/ApiResponse.cs @@ -25,7 +25,14 @@ public void EnsureAvailableStatusCode() if (HttpStatusCode == HttpStatusCode.Gone) { throw new ErrorHandling.GoneException( httpResponse.ReasonPhrase, - Value); + Value) + { + // TO-DO: Make async in v5 + ResponseBody = httpResponse.Content.ReadAsStringAsync() + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(), + }; } } } diff --git a/src/SignhostAPIClient/Rest/ErrorHandling/BadAuthorizationException.cs b/src/SignhostAPIClient/Rest/ErrorHandling/BadAuthorizationException.cs index 04bc32b..7dc3a19 100644 --- a/src/SignhostAPIClient/Rest/ErrorHandling/BadAuthorizationException.cs +++ b/src/SignhostAPIClient/Rest/ErrorHandling/BadAuthorizationException.cs @@ -7,6 +7,7 @@ namespace Signhost.APIClient.Rest.ErrorHandling { + // TO-DO: Use this instead of Unauthorized exception in v5 [Serializable] public class BadAuthorizationException : SignhostRestApiClientException diff --git a/src/SignhostAPIClient/Rest/ErrorHandling/HttpResponseMessageErrorHandlingExtensions.cs b/src/SignhostAPIClient/Rest/ErrorHandling/HttpResponseMessageErrorHandlingExtensions.cs index 745b17c..3ad5df5 100644 --- a/src/SignhostAPIClient/Rest/ErrorHandling/HttpResponseMessageErrorHandlingExtensions.cs +++ b/src/SignhostAPIClient/Rest/ErrorHandling/HttpResponseMessageErrorHandlingExtensions.cs @@ -12,6 +12,9 @@ namespace Signhost.APIClient.Rest.ErrorHandling /// public static class HttpResponseMessageErrorHandlingExtensions { + private const string OutOfCreditsApiProblemType = + "https://api.signhost.com/problem/subscription/out-of-credits"; + /// /// Throws an exception if the /// has an error code. @@ -55,13 +58,14 @@ public static async Task EnsureSignhostSuccessStatusCodeAsy string errorType = string.Empty; string errorMessage = "Unknown Signhost error"; + string responseBody = string.Empty; if (response.Content != null) { - string responsejson = await response.Content.ReadAsStringAsync() + responseBody = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); var error = JsonConvert.DeserializeAnonymousType( - responsejson, + responseBody, new { Type = string.Empty, Message = string.Empty, @@ -71,33 +75,46 @@ public static async Task EnsureSignhostSuccessStatusCodeAsy errorMessage = error.Message; } + // TO-DO: Use switch pattern in v5 + Exception exception = null; switch (response.StatusCode) { case HttpStatusCode.Unauthorized: - throw new System.UnauthorizedAccessException( + exception = new UnauthorizedAccessException( errorMessage); + break; + case HttpStatusCode.BadRequest: - throw new BadRequestException( + exception = new BadRequestException( errorMessage); - case HttpStatusCode.PaymentRequired - when errorType == "https://api.signhost.com/problem/subscription/out-of-credits": - if (string.IsNullOrEmpty(errorMessage)) { - errorMessage = "The credit bundle has been exceeded."; - } + break; - throw new OutOfCreditsException( + case HttpStatusCode.PaymentRequired + when errorType == OutOfCreditsApiProblemType: + exception = new OutOfCreditsException( errorMessage); + break; + case HttpStatusCode.NotFound: - throw new NotFoundException( + exception = new NotFoundException( errorMessage); + break; + case HttpStatusCode.InternalServerError: - throw new InternalServerErrorException( + exception = new InternalServerErrorException( errorMessage, response.Headers.RetryAfter); + break; + default: - throw new SignhostRestApiClientException( + exception = new SignhostRestApiClientException( errorMessage); + break; + } + + if (exception is SignhostRestApiClientException signhostException) { + signhostException.ResponseBody = responseBody; } - System.Diagnostics.Debug.Fail("Should not be reached"); + throw exception; } } } diff --git a/src/SignhostAPIClient/Rest/ErrorHandling/SignhostException.cs b/src/SignhostAPIClient/Rest/ErrorHandling/SignhostException.cs index fd06b20..a0e7bcf 100644 --- a/src/SignhostAPIClient/Rest/ErrorHandling/SignhostException.cs +++ b/src/SignhostAPIClient/Rest/ErrorHandling/SignhostException.cs @@ -3,6 +3,7 @@ namespace Signhost.APIClient.Rest.ErrorHandling { + // TO-DO: Remove in v5 [Serializable] [Obsolete("Unused will be removed")] public class SignhostException diff --git a/src/SignhostAPIClient/Rest/ErrorHandling/SignhostRestApiClientException.cs b/src/SignhostAPIClient/Rest/ErrorHandling/SignhostRestApiClientException.cs index 600a3c5..ff620e0 100644 --- a/src/SignhostAPIClient/Rest/ErrorHandling/SignhostRestApiClientException.cs +++ b/src/SignhostAPIClient/Rest/ErrorHandling/SignhostRestApiClientException.cs @@ -33,5 +33,10 @@ protected SignhostRestApiClientException( { } #endif + + /// + /// Gets or sets the response body returned from the Signhost REST API. + /// + public string ResponseBody { get; set; } } } From 9b976acd83690fb9c789d0285c1244d462537f5e Mon Sep 17 00:00:00 2001 From: Anthony Timmers Date: Thu, 27 Nov 2025 17:24:19 +0100 Subject: [PATCH 3/4] Fix non-breaking data object discrepencies with API docs --- src/SignhostAPIClient/Rest/DataObjects/Activity.cs | 4 ++++ .../Rest/DataObjects/ActivityType.cs | 1 + .../Rest/DataObjects/DigidVerification.cs | 2 ++ src/SignhostAPIClient/Rest/DataObjects/Field.cs | 2 ++ .../Rest/DataObjects/IVerification.cs | 1 + .../Rest/DataObjects/PhoneNumberVerification.cs | 2 ++ src/SignhostAPIClient/Rest/DataObjects/Receiver.cs | 9 ++++++++- src/SignhostAPIClient/Rest/DataObjects/Signer.cs | 14 ++++++++++++++ .../Rest/DataObjects/SurfnetVerification.cs | 8 +++++++- 9 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/SignhostAPIClient/Rest/DataObjects/Activity.cs b/src/SignhostAPIClient/Rest/DataObjects/Activity.cs index dccf8ca..31a703c 100644 --- a/src/SignhostAPIClient/Rest/DataObjects/Activity.cs +++ b/src/SignhostAPIClient/Rest/DataObjects/Activity.cs @@ -1,4 +1,5 @@ using System; +using Newtonsoft.Json; namespace Signhost.APIClient.Rest.DataObjects { @@ -8,6 +9,9 @@ public class Activity public ActivityType Code { get; set; } + [JsonProperty("Activity")] + public string ActivityValue { get; set; } + public string Info { get; set; } public DateTimeOffset CreatedDateTime { get; set; } diff --git a/src/SignhostAPIClient/Rest/DataObjects/ActivityType.cs b/src/SignhostAPIClient/Rest/DataObjects/ActivityType.cs index e954356..4b85586 100644 --- a/src/SignhostAPIClient/Rest/DataObjects/ActivityType.cs +++ b/src/SignhostAPIClient/Rest/DataObjects/ActivityType.cs @@ -7,6 +7,7 @@ namespace Signhost.APIClient.Rest.DataObjects /// /// type. /// + // TO-DO: Remove unused activity types in v5. public enum ActivityType { /// diff --git a/src/SignhostAPIClient/Rest/DataObjects/DigidVerification.cs b/src/SignhostAPIClient/Rest/DataObjects/DigidVerification.cs index b403481..c5be757 100644 --- a/src/SignhostAPIClient/Rest/DataObjects/DigidVerification.cs +++ b/src/SignhostAPIClient/Rest/DataObjects/DigidVerification.cs @@ -6,5 +6,7 @@ public class DigidVerification public string Type => "DigiD"; public string Bsn { get; set; } + + public bool? SecureDownload { get; set; } } } diff --git a/src/SignhostAPIClient/Rest/DataObjects/Field.cs b/src/SignhostAPIClient/Rest/DataObjects/Field.cs index 2c2482b..a7453c8 100644 --- a/src/SignhostAPIClient/Rest/DataObjects/Field.cs +++ b/src/SignhostAPIClient/Rest/DataObjects/Field.cs @@ -2,8 +2,10 @@ { public class Field { + // TO-DO: Make enum in v5. public string Type { get; set; } + // TO-DO: Can be boolean, number, string, should be fixed in v5. public string Value { get; set; } public Location Location { get; set; } diff --git a/src/SignhostAPIClient/Rest/DataObjects/IVerification.cs b/src/SignhostAPIClient/Rest/DataObjects/IVerification.cs index fe76ce2..5ab6b93 100644 --- a/src/SignhostAPIClient/Rest/DataObjects/IVerification.cs +++ b/src/SignhostAPIClient/Rest/DataObjects/IVerification.cs @@ -3,6 +3,7 @@ namespace Signhost.APIClient.Rest.DataObjects { + // TO-DO: Split to verification and authentication in v5 [JsonConverter(typeof(JsonVerificationConverter))] public interface IVerification { diff --git a/src/SignhostAPIClient/Rest/DataObjects/PhoneNumberVerification.cs b/src/SignhostAPIClient/Rest/DataObjects/PhoneNumberVerification.cs index 8503fbf..14b3870 100644 --- a/src/SignhostAPIClient/Rest/DataObjects/PhoneNumberVerification.cs +++ b/src/SignhostAPIClient/Rest/DataObjects/PhoneNumberVerification.cs @@ -6,5 +6,7 @@ public class PhoneNumberVerification public string Type => "PhoneNumber"; public string Number { get; set; } + + public bool? SecureDownload { get; set; } } } diff --git a/src/SignhostAPIClient/Rest/DataObjects/Receiver.cs b/src/SignhostAPIClient/Rest/DataObjects/Receiver.cs index 8fc286e..5f446c7 100644 --- a/src/SignhostAPIClient/Rest/DataObjects/Receiver.cs +++ b/src/SignhostAPIClient/Rest/DataObjects/Receiver.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Newtonsoft.Json; namespace Signhost.APIClient.Rest.DataObjects @@ -15,6 +16,8 @@ private Receiver(IReadOnlyList activities) Activities = activities; } + public string Id { get; set; } + public string Name { get; set; } public string Email { get; set; } @@ -27,6 +30,10 @@ private Receiver(IReadOnlyList activities) public string Reference { get; set; } + public DateTimeOffset? CreatedDateTime { get; set; } + + public DateTimeOffset? ModifiedDateTime { get; set; } + public IReadOnlyList Activities { get; set; } = new List().AsReadOnly(); diff --git a/src/SignhostAPIClient/Rest/DataObjects/Signer.cs b/src/SignhostAPIClient/Rest/DataObjects/Signer.cs index 77cada5..e3c8870 100644 --- a/src/SignhostAPIClient/Rest/DataObjects/Signer.cs +++ b/src/SignhostAPIClient/Rest/DataObjects/Signer.cs @@ -64,6 +64,20 @@ private Signer(IReadOnlyList activities) public string DelegateSignerName { get; set; } + public DateTimeOffset? SignedDateTime { get; set; } + + public DateTimeOffset? RejectDateTime { get; set; } + + public DateTimeOffset? CreatedDateTime { get; set; } + + public DateTimeOffset? SignerDelegationDateTime { get; set; } + + public DateTimeOffset? ModifiedDateTime { get; set; } + + public string ShowUrl { get; set; } + + public string ReceiptUrl { get; set; } + public IReadOnlyList Activities { get; private set; } = new List().AsReadOnly(); diff --git a/src/SignhostAPIClient/Rest/DataObjects/SurfnetVerification.cs b/src/SignhostAPIClient/Rest/DataObjects/SurfnetVerification.cs index 466551c..c65632f 100644 --- a/src/SignhostAPIClient/Rest/DataObjects/SurfnetVerification.cs +++ b/src/SignhostAPIClient/Rest/DataObjects/SurfnetVerification.cs @@ -1,8 +1,14 @@ -namespace Signhost.APIClient.Rest.DataObjects +using System.Collections.Generic; + +namespace Signhost.APIClient.Rest.DataObjects { public class SurfnetVerification : IVerification { public string Type => "SURFnet"; + + public string Uid { get; set; } + + public IDictionary Attributes { get; set; } } } From 6f004ef29150bfab2e7e96ea8393caf06ef9b51b Mon Sep 17 00:00:00 2001 From: Anthony Timmers Date: Thu, 27 Nov 2025 19:15:22 +0100 Subject: [PATCH 4/4] Add integration tests --- .github/workflows/publish_nuget_package.yml | 1 + .../README.md | 27 ++ .../SignhostAPIClient.IntegrationTests.csproj | 32 +++ .../TestConfiguration.cs | 38 +++ .../TestFiles/small-example-pdf-file.pdf | Bin 0 -> 4107 bytes .../TransactionTests.cs | 261 ++++++++++++++++++ src/SignhostAPIClient.sln | 6 + 7 files changed, 365 insertions(+) create mode 100644 src/SignhostAPIClient.IntegrationTests/README.md create mode 100644 src/SignhostAPIClient.IntegrationTests/SignhostAPIClient.IntegrationTests.csproj create mode 100644 src/SignhostAPIClient.IntegrationTests/TestConfiguration.cs create mode 100644 src/SignhostAPIClient.IntegrationTests/TestFiles/small-example-pdf-file.pdf create mode 100644 src/SignhostAPIClient.IntegrationTests/TransactionTests.cs diff --git a/.github/workflows/publish_nuget_package.yml b/.github/workflows/publish_nuget_package.yml index dc459d1..77780ea 100644 --- a/.github/workflows/publish_nuget_package.yml +++ b/.github/workflows/publish_nuget_package.yml @@ -53,6 +53,7 @@ jobs: - name: Test with Coverage run: dotnet test --no-build src/SignhostAPIClient.Tests/SignhostAPIClient.Tests.csproj --collect:"XPlat Code Coverage" -c Release + # Note: Integration tests are excluded from CI/CD as they require live credentials - name: Pack run: dotnet pack src/SignhostAPIClient/SignhostAPIClient.csproj /p:Version=${{ env.LATEST_VERSION }} diff --git a/src/SignhostAPIClient.IntegrationTests/README.md b/src/SignhostAPIClient.IntegrationTests/README.md new file mode 100644 index 0000000..2caaae0 --- /dev/null +++ b/src/SignhostAPIClient.IntegrationTests/README.md @@ -0,0 +1,27 @@ +# Integration Tests + +This project contains integration tests that require live API credentials to run. + +## Configuration + +The tests require Signhost API credentials configured via .NET User Secrets: + +```bash +cd src/SignhostAPIClient.IntegrationTests +dotnet user-secrets set "Signhost:AppKey" "your-app-key-here" +dotnet user-secrets set "Signhost:UserToken" "your-user-token-here" +dotnet user-secrets set "Signhost:ApiBaseUrl" "https://api.signhost.com/api" +``` + +## Running Tests + +```bash +dotnet test src/SignhostAPIClient.IntegrationTests/SignhostAPIClient.IntegrationTests.csproj +``` + +## Important Notes + +- These tests are **excluded from CI/CD** pipelines +- These tests are **not packaged** in the NuGet package +- Tests will fail if credentials are not configured +- Tests create real transactions in your Signhost account diff --git a/src/SignhostAPIClient.IntegrationTests/SignhostAPIClient.IntegrationTests.csproj b/src/SignhostAPIClient.IntegrationTests/SignhostAPIClient.IntegrationTests.csproj new file mode 100644 index 0000000..56cc88e --- /dev/null +++ b/src/SignhostAPIClient.IntegrationTests/SignhostAPIClient.IntegrationTests.csproj @@ -0,0 +1,32 @@ + + + net8.0 + false + true + ../signhost.ruleset + signhost-api-client-integration-tests + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/src/SignhostAPIClient.IntegrationTests/TestConfiguration.cs b/src/SignhostAPIClient.IntegrationTests/TestConfiguration.cs new file mode 100644 index 0000000..53a7e10 --- /dev/null +++ b/src/SignhostAPIClient.IntegrationTests/TestConfiguration.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.Extensions.Configuration; + +namespace Signhost.APIClient.Rest.IntegrationTests; + +/// +/// Configuration for integration tests loaded from user secrets only. +/// No appsettings.json is used to prevent accidental credential commits. +/// +public class TestConfiguration +{ + private static readonly Lazy LazyInstance = + new(() => new TestConfiguration()); + + private TestConfiguration() + { + var builder = new ConfigurationBuilder() + .AddUserSecrets(optional: false); + + IConfiguration configuration = builder.Build(); + AppKey = configuration["Signhost:AppKey"]; + UserToken = configuration["Signhost:UserToken"]; + ApiBaseUrl = configuration["Signhost:ApiBaseUrl"]; + } + + public static TestConfiguration Instance => LazyInstance.Value; + + public string AppKey { get; } + + public string UserToken { get; } + + public string ApiBaseUrl { get; } + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(AppKey) && + !string.IsNullOrWhiteSpace(UserToken) && + !string.IsNullOrWhiteSpace(ApiBaseUrl); +} diff --git a/src/SignhostAPIClient.IntegrationTests/TestFiles/small-example-pdf-file.pdf b/src/SignhostAPIClient.IntegrationTests/TestFiles/small-example-pdf-file.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fd46350c2b98bff7f1971081b2b8da9ee807a6c0 GIT binary patch literal 4107 zcmeHKUyK{Y8Bd^4X-g`Ei26{3MnR{&imYd5?cF^)wo`LHH!0_fYa4OsIHbMZ@!jUu zyYB9~_FYvJghZvLB0K~hsok2&p8Zv}#cWJVYX?)Q3KW1XNV1X{Gj? z@viMVN99k+TW$ICw=>_r`M%$LvolpIR}`AcNKNz}aLr zqIe!s|`pf zq8WVHYxxLbLv>vD{Rn{xU=Np<@_IR2QmLww(ws(0v!q-iS=E?R)pABnr?uJQCrd2j zTEM;s^)P0j9${n#5zp!#iAYmZb4MzJ+DP1IT9r?!I|t*U3Hf7w=qrtUR^w%7_0refD>AE9SAY zFWT9UzK{QYVDU>U$F2S6TjXme-nw$))(C>nG^_6qS zub%q0a%AfV$M32*czeFD&`t!S$%hsi>`m;}8NWC#_zg50+ zsds9#a`OGB<)`kv>D8~}+Z$_V?tcB^#uGojG=T~l8&b9;4*nYY2NhWb?*|No7%f}3kK-TdpN)Q zJ7Q!v(80_=@XPSWf5{m9^Vqj1k)t?{0|0M#Ef!2aoHlg$84p8~sc}-~q{7K8Cjlpn z6O)sO6Q2_?fjFmflH!C6xv{Ztz&?ReMvm{50rk`6oJy!h3}{S5)l~IfN@PN05Q8T0 z2@*{wa@n?+MYtj$i#zXf(&j|O>~T)SOu>Gs8?E`;oKO;6I8+y_-I)O} z33xfmtY{k8``Jd%5A2ZhV!=CH-Jt9aVLk#ObTH9{2hA7X@7N()l6cRoOUuxl{Vs1~ zW9@1Tpkgm1eRs4U7OV3(#xB)#I1FNBrCJc0qnntCDJG zmaRF5HUrk!TxeRG>sHp54;H2TK@(1Pru5B@(h@B(L@cxl`)2F&m3>^GpX;|fZD{|f z-*&w)*DuIIB?mko#xfEj(Rx8%tkzKE`|i2}y*-spQH7@WqgG(H85Cq4(UdNO$-r*q z>d@W?2H~k7uSHQOhjFjhOZCzzKUl>yo6TaPVpUZ@LJ2p#$m}a#cn|m!fjEmpH7`tw zm{OMC#?B(9DS{J6Wmrz&bU5e{cWkb}saKG@UB}KDD@Lo8VTO{{(iw$PrYV3rqEMUY zOt-U)sA}S_GZyAHe=q?K)8H@@Ifv}3VwOKO+XUz30RAR+2Ny<$2{Dz z@Z-#ljr>mwg0fyefq;cLgL#n2K`(Gx8@wG9dAm@Ep{RP3kesUJM9;_(SlJFqzzwL1 z^|Mc-Iwm=$>IY3g({(dBG#|^{@NAi#YW2V8tKH^%fy?E}zSV8R`^e$)XF(eNf-7uW z!+<;8z!lrdwlH%Uk1d2EQ;68MaxFh-o3JU&PRDgDaX@gW0-|9u;+?)IHf_m`28`?Q zO_*~{Z?z!z6`Qrpt{ch4WZiU74%Y!xa}*0jJTeBH#ZMwX7}#sD? zy~B=><93GHz}ur8{wc%UVRabYHcQvpeqsnZ7t{$4`(p9)UWJ zd%V?8yo4lf|NnoZG9d1~1Z9#dE}Sh$*R-^TTFJGSW-@T*^UvriOtX{MUYebOm*+%o T=j}5iURUEgp+qx!d@1}7-TrPa literal 0 HcmV?d00001 diff --git a/src/SignhostAPIClient.IntegrationTests/TransactionTests.cs b/src/SignhostAPIClient.IntegrationTests/TransactionTests.cs new file mode 100644 index 0000000..e395c87 --- /dev/null +++ b/src/SignhostAPIClient.IntegrationTests/TransactionTests.cs @@ -0,0 +1,261 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using FluentAssertions; +using Signhost.APIClient.Rest.DataObjects; +using Xunit; + +namespace Signhost.APIClient.Rest.IntegrationTests; + +public class TransactionTests + : IDisposable +{ + private readonly SignHostApiClient client; + private readonly TestConfiguration config; + + public TransactionTests() + { + config = TestConfiguration.Instance; + + if (!config.IsConfigured) { + throw new InvalidOperationException( + "Integration tests are not configured"); + } + + var settings = new SignHostApiClientSettings(config.AppKey, config.UserToken) { + Endpoint = config.ApiBaseUrl + }; + + client = new SignHostApiClient(settings); + } + + [Fact] + public async Task Given_complex_transaction_When_created_and_started_Then_all_properties_are_correctly_persisted() + { + // Arrange + var testReference = $"IntegrationTest-{DateTime.UtcNow:yyyyMMddHHmmss}"; + var testPostbackUrl = "https://example.com/postback"; + var signerEmail = "john.doe@example.com"; + var signerReference = "SIGNER-001"; + var signerIntroText = "Please review and sign this document carefully."; + var signerExpires = DateTimeOffset.UtcNow.AddDays(15); + var receiverEmail = "receiver@example.com"; + var receiverName = "Jane Receiver"; + var receiverReference = "RECEIVER-001"; + + var transaction = new Transaction { + Seal = false, + Reference = testReference, + PostbackUrl = testPostbackUrl, + DaysToExpire = 30, + SendEmailNotifications = false, + SignRequestMode = 2, + Language = "en-US", + Context = new { + TestContext = "integration-test", + }, + Signers = [ + new Signer { + Id = "signer1", + Email = signerEmail, + Reference = signerReference, + IntroText = signerIntroText, + Expires = signerExpires, + SendSignRequest = false, + SendSignConfirmation = false, + DaysToRemind = 7, + Language = "en-US", + SignRequestMessage = "Please sign this document.", + SignRequestSubject = "Document for Signature", + ReturnUrl = "https://example.com/return", + AllowDelegation = false, + Context = new { + SignerContext = "test-signer", + }, + Verifications = [ + new ScribbleVerification { + RequireHandsignature = true, + ScribbleName = "John Doe", + ScribbleNameFixed = true + } + ], + Authentications = [ + new PhoneNumberVerification { + Number = "+31612345678", + SecureDownload = true, + } + ] + } + ], + Receivers = [ + new Receiver { + Name = receiverName, + Email = receiverEmail, + Language = "en-US", + Message = "The document has been signed.", + Subject = "Signed Document", + Reference = receiverReference, + Context = new { + ReceiverContext = "test-receiver", + } + } + ] + }; + + var pdfPath = Path.Combine("TestFiles", "small-example-pdf-file.pdf"); + if (!File.Exists(pdfPath)) { + throw new FileNotFoundException($"Test PDF file not found at: {pdfPath}"); + } + + // Act - Create transaction + var createdTransaction = await client.CreateTransactionAsync(transaction); + + // Assert - Creation properties + createdTransaction.Should().NotBeNull(); + createdTransaction.Id.Should().NotBeNullOrEmpty(); + createdTransaction.Status.Should().Be(TransactionStatus.WaitingForDocument); + createdTransaction.Seal.Should().BeFalse(); + createdTransaction.Reference.Should().Be(testReference); + createdTransaction.PostbackUrl.Should().Be(testPostbackUrl); + createdTransaction.DaysToExpire.Should().Be(30); + createdTransaction.SendEmailNotifications.Should().BeFalse(); + createdTransaction.SignRequestMode.Should().Be(2); + createdTransaction.Language.Should().Be("en-US"); + createdTransaction.CreatedDateTime.Should().HaveValue(); + createdTransaction.CreatedDateTime.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1)); + createdTransaction.CancelledDateTime.Should().BeNull(); + createdTransaction.CancellationReason.Should().BeNull(); + + // Assert - Context + ((object)createdTransaction.Context).Should().NotBeNull(); + string transactionContextJson = createdTransaction.Context.ToString(); + transactionContextJson.Should().Contain("integration-test"); + + // Assert - Signers + createdTransaction.Signers.Should().HaveCount(1); + var createdSigner = createdTransaction.Signers[0]; + createdSigner.Id.Should().Be("signer1"); + createdSigner.Email.Should().Be(signerEmail); + createdSigner.Reference.Should().Be(signerReference); + createdSigner.IntroText.Should().Be(signerIntroText); + createdSigner.Expires.Should().HaveValue(); + createdSigner.Expires.Should().BeCloseTo(signerExpires, TimeSpan.FromMinutes(1)); + createdSigner.SendSignRequest.Should().BeFalse(); + createdSigner.SendSignConfirmation.Should().BeFalse(); + createdSigner.DaysToRemind.Should().Be(7); + createdSigner.Language.Should().Be("en-US"); + createdSigner.SignRequestMessage.Should().Be("Please sign this document."); + createdSigner.SignRequestSubject.Should().Be("Document for Signature"); + createdSigner.ReturnUrl.Should().Be("https://example.com/return"); + createdSigner.AllowDelegation.Should().BeFalse(); + createdSigner.CreatedDateTime.Should().HaveValue(); + createdSigner.ModifiedDateTime.Should().HaveValue(); + createdSigner.SignedDateTime.Should().BeNull(); + createdSigner.RejectDateTime.Should().BeNull(); + createdSigner.SignerDelegationDateTime.Should().BeNull(); + createdSigner.RejectReason.Should().BeNull(); + createdSigner.Activities.Should().NotBeNull(); + + // Assert - Signer Context + ((object)createdSigner.Context).Should().NotBeNull(); + string signerContextJson = createdSigner.Context.ToString(); + signerContextJson.Should().Contain("test-signer"); + + // Assert - Signer Verifications + createdSigner.Verifications.Should().HaveCount(1); + var verification = createdSigner.Verifications[0].Should().BeOfType().Subject; + verification.ScribbleName.Should().Be("John Doe"); + verification.RequireHandsignature.Should().BeTrue(); + verification.ScribbleNameFixed.Should().BeTrue(); + + // Assert - Signer Authentications + createdSigner.Authentications.Should().HaveCount(1); + var authentication = createdSigner.Authentications[0].Should().BeOfType().Subject; + authentication.Number.Should().Be("+31612345678"); + authentication.SecureDownload.Should().BeTrue(); + + // Assert - Receivers + createdTransaction.Receivers.Should().HaveCount(1); + var createdReceiver = createdTransaction.Receivers[0]; + createdReceiver.Name.Should().Be(receiverName); + createdReceiver.Email.Should().Be(receiverEmail); + createdReceiver.Language.Should().Be("en-US"); + createdReceiver.Message.Should().Be("The document has been signed."); + createdReceiver.Subject.Should().Be("Signed Document"); + createdReceiver.Reference.Should().Be(receiverReference); + createdReceiver.Id.Should().NotBeNullOrEmpty(); + createdReceiver.CreatedDateTime.Should().HaveValue(); + createdReceiver.ModifiedDateTime.Should().HaveValue(); + createdReceiver.Activities.Should().BeNull( + because: "actual API inconsistency - Receiver Activities are null rather than an empty list"); + + // Assert - Receiver Context + ((object)createdReceiver.Context).Should().NotBeNull(); + string receiverContextJson = createdReceiver.Context.ToString(); + receiverContextJson.Should().Contain("test-receiver"); + + // Act - Upload file + await using var fileStream = File.OpenRead(pdfPath); + await client.AddOrReplaceFileToTransactionAsync( + fileStream, + createdTransaction.Id, + "test-document.pdf", + new FileUploadOptions()); + + // Act - Start transaction + await client.StartTransactionAsync(createdTransaction.Id); + + // Act - Retrieve final state + var finalTransaction = await client.GetTransactionAsync(createdTransaction.Id); + + // Assert - Final transaction state + finalTransaction.Should().NotBeNull(); + finalTransaction.Id.Should().Be(createdTransaction.Id); + finalTransaction.Status.Should().BeOneOf( + TransactionStatus.WaitingForSigner, + TransactionStatus.InProgress); + finalTransaction.Reference.Should().Be(testReference); + finalTransaction.PostbackUrl.Should().Be(testPostbackUrl); + finalTransaction.DaysToExpire.Should().Be(30); + finalTransaction.SendEmailNotifications.Should().BeFalse(); + finalTransaction.Language.Should().Be("en-US"); + + // Assert - Files + finalTransaction.Files.Should().NotBeNull(); + finalTransaction.Files.Should().ContainKey("test-document.pdf"); + var fileEntry = finalTransaction.Files["test-document.pdf"]; + fileEntry.Should().NotBeNull(); + fileEntry.DisplayName.Should().Be("test-document.pdf"); + fileEntry.Links.Should().NotBeNull().And.NotBeEmpty(); + + // Assert - Signer in final state + finalTransaction.Signers.Should().HaveCount(1); + var finalSigner = finalTransaction.Signers[0]; + finalSigner.Id.Should().Be("signer1"); + finalSigner.Email.Should().Be(signerEmail); + finalSigner.Reference.Should().Be(signerReference); + finalSigner.ModifiedDateTime.Should().HaveValue(); + finalSigner.ModifiedDateTime.Should().BeOnOrAfter(finalSigner.CreatedDateTime.Value); + finalSigner.Expires.Should().HaveValue(); + finalSigner.SignUrl.Should().NotBeNullOrEmpty(); + finalSigner.ShowUrl.Should().NotBeNullOrEmpty(); + finalSigner.ReceiptUrl.Should().NotBeNullOrEmpty(); + finalSigner.DelegateSignUrl.Should().BeNullOrEmpty(); + finalSigner.DelegateReason.Should().BeNullOrEmpty(); + finalSigner.DelegateSignerEmail.Should().BeNullOrEmpty(); + finalSigner.DelegateSignerName.Should().BeNullOrEmpty(); + + // Assert - Receiver in final state + finalTransaction.Receivers.Should().HaveCount(1); + var finalReceiver = finalTransaction.Receivers[0]; + finalReceiver.Email.Should().Be(receiverEmail); + finalReceiver.Name.Should().Be(receiverName); + finalReceiver.Reference.Should().Be(receiverReference); + } + + public void Dispose() + { + client?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/SignhostAPIClient.sln b/src/SignhostAPIClient.sln index 1dc1d2d..fdd4c33 100644 --- a/src/SignhostAPIClient.sln +++ b/src/SignhostAPIClient.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SignhostAPIClient", "Signho EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SignhostAPIClient.Tests", "SignhostAPIClient.Tests\SignhostAPIClient.Tests.csproj", "{0A2CF5DE-060C-4C92-8F15-7AA26268511D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SignhostAPIClient.IntegrationTests", "SignhostAPIClient.IntegrationTests\SignhostAPIClient.IntegrationTests.csproj", "{B8F3E9A1-3C4D-4E5F-9A2B-1D3E4F5A6B7C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {0A2CF5DE-060C-4C92-8F15-7AA26268511D}.Debug|Any CPU.Build.0 = Debug|Any CPU {0A2CF5DE-060C-4C92-8F15-7AA26268511D}.Release|Any CPU.ActiveCfg = Release|Any CPU {0A2CF5DE-060C-4C92-8F15-7AA26268511D}.Release|Any CPU.Build.0 = Release|Any CPU + {B8F3E9A1-3C4D-4E5F-9A2B-1D3E4F5A6B7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8F3E9A1-3C4D-4E5F-9A2B-1D3E4F5A6B7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8F3E9A1-3C4D-4E5F-9A2B-1D3E4F5A6B7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8F3E9A1-3C4D-4E5F-9A2B-1D3E4F5A6B7C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE