diff --git a/src/RobinTTY.NordigenApiClient.Tests/JsonWebTokenPairTests.cs b/src/RobinTTY.NordigenApiClient.Tests/AuthenticationTests.cs similarity index 78% rename from src/RobinTTY.NordigenApiClient.Tests/JsonWebTokenPairTests.cs rename to src/RobinTTY.NordigenApiClient.Tests/AuthenticationTests.cs index 67544b7..2defee9 100644 --- a/src/RobinTTY.NordigenApiClient.Tests/JsonWebTokenPairTests.cs +++ b/src/RobinTTY.NordigenApiClient.Tests/AuthenticationTests.cs @@ -1,17 +1,22 @@ using Microsoft.IdentityModel.JsonWebTokens; +using RobinTTY.NordigenApiClient.Models; using RobinTTY.NordigenApiClient.Models.Jwt; using RobinTTY.NordigenApiClient.Utility; namespace RobinTTY.NordigenApiClient.Tests; -internal class JsonWebTokenPairTests +/// +/// Tests aspects of authentication related to the and classes. +/// +internal class AuthenticationTests { - private NordigenClient _apiClient = null!; - - [OneTimeSetUp] - public void Setup() + /// + /// Tests creating , passing null as an argument. + /// + [Test] + public void CreateCredentialsWithNull() { - _apiClient = TestExtensions.GetConfiguredClient(); + Assert.Throws(() => { _ = new NordigenClientCredentials(null!, null!); }); } /// @@ -48,22 +53,6 @@ public void CreateInvalidJsonWebTokenPair() Assert.Throws(() => new JsonWebTokenPair(exampleToken, exampleToken)); } - /// - /// Tests that is populated after the first authenticated request is made. - /// - [Test] - public async Task CheckValidTokensAfterRequest() - { - Assert.That(_apiClient.JsonWebTokenPair, Is.Null); - await _apiClient.RequisitionsEndpoint.GetRequisitions(5, 0, CancellationToken.None); - Assert.Multiple(() => - { - Assert.That(_apiClient.JsonWebTokenPair, Is.Not.Null); - Assert.That(_apiClient.JsonWebTokenPair!.AccessToken.EncodedToken, Has.Length.GreaterThan(0)); - Assert.That(_apiClient.JsonWebTokenPair!.RefreshToken.EncodedToken, Has.Length.GreaterThan(0)); - }); - } - /// /// Tests the token expiry extension method for correct behavior respecting time zones. /// diff --git a/src/RobinTTY.NordigenApiClient.Tests/CredentialTests.cs b/src/RobinTTY.NordigenApiClient.Tests/CredentialTests.cs deleted file mode 100644 index 9a0c2fa..0000000 --- a/src/RobinTTY.NordigenApiClient.Tests/CredentialTests.cs +++ /dev/null @@ -1,85 +0,0 @@ -using RobinTTY.NordigenApiClient.Models; -using RobinTTY.NordigenApiClient.Models.Errors; -using RobinTTY.NordigenApiClient.Models.Requests; - -namespace RobinTTY.NordigenApiClient.Tests; - -/// -/// Tests the instantiation of the . -/// -internal class CredentialTests -{ - private readonly string[] _secrets = File.ReadAllLines("secrets.txt"); - - /// - /// Tests the creation of the with invalid credentials. - /// The credentials have the correct structure but were not issued for use. - /// - /// - [Test] - public async Task MakeRequestWithInvalidCredentials() - { - using var httpClient = new HttpClient(); - var invalidCredentials = new NordigenClientCredentials("01234567-89ab-cdef-0123-456789abcdef", - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); - var apiClient = new NordigenClient(httpClient, invalidCredentials); - - // Returns BasicError - var agreementsResponse = await apiClient.AgreementsEndpoint.GetAgreements(10, 0); - Assert.That(ErrorMatchesExpectation(agreementsResponse.Error!), Is.True); - - // Returns InstitutionsError - var institutionResponse = await apiClient.InstitutionsEndpoint.GetInstitutions(); - Assert.That(ErrorMatchesExpectation(institutionResponse.Error!), Is.True); - - // Returns AccountsError - var balancesResponse = await apiClient.AccountsEndpoint.GetBalances(_secrets[9]); - Assert.Multiple(() => - { - Assert.That(ErrorMatchesExpectation(balancesResponse.Error!), Is.True); - Assert.That( - new object?[] - { - balancesResponse.Error!.EndDateError, balancesResponse.Error.StartDateError, - balancesResponse.Error.Type - }, Has.All.Null); - }); - - // Returns CreateAgreementError - var agreementRequest = new CreateAgreementRequest(90, 90, - new List {"balances", "details", "transactions"}, "SANDBOXFINANCE_SFIN0000"); - var createAgreementResponse = await apiClient.AgreementsEndpoint.CreateAgreement(agreementRequest); - Assert.Multiple(() => - { - Assert.That(ErrorMatchesExpectation(createAgreementResponse.Error!), Is.True); - Assert.That(new object?[] - { - createAgreementResponse.Error!.AccessScopeError, createAgreementResponse.Error.AccessValidForDaysError, - createAgreementResponse.Error.AgreementError, - createAgreementResponse.Error.InstitutionIdError, createAgreementResponse.Error.MaxHistoricalDaysError - }, Has.All.Null); - }); - - // Returns CreateRequisitionError - const string institutionId = "SANDBOXFINANCE_SFIN0000"; - var requisitionRequest = - new CreateRequisitionRequest(new Uri("https://robintty.com"), institutionId, "some_reference", "EN"); - var requisitionResponse = await apiClient.RequisitionsEndpoint.CreateRequisition(requisitionRequest); - Assert.Multiple(() => - { - Assert.That(ErrorMatchesExpectation(requisitionResponse.Error!), Is.True); - Assert.That(new object?[] - { - requisitionResponse.Error!.AgreementError, requisitionResponse.Error.InstitutionIdError, - requisitionResponse.Error.AccountSelectionError, - requisitionResponse.Error.RedirectError, requisitionResponse.Error.ReferenceError, - requisitionResponse.Error.SocialSecurityNumberError, requisitionResponse.Error.UserLanguageError - }, Has.All.Null); - }); - } - - private static bool ErrorMatchesExpectation(BasicError error) - { - return error is {Detail: "Authentication credentials were not provided.", Summary: "Authentication failed"}; - } -} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Endpoints/AccountsEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/Endpoints/AccountsEndpointTests.cs deleted file mode 100644 index ce2d5c9..0000000 --- a/src/RobinTTY.NordigenApiClient.Tests/Endpoints/AccountsEndpointTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Net; -using RobinTTY.NordigenApiClient.Models.Responses; - -namespace RobinTTY.NordigenApiClient.Tests.Endpoints; - -internal class AccountsEndpointTests -{ - private readonly string[] _secrets = File.ReadAllLines("secrets.txt"); - private Guid _accountId; - private NordigenClient _apiClient = null!; - - [OneTimeSetUp] - public void Setup() - { - _accountId = Guid.Parse(_secrets[9]); - _apiClient = TestExtensions.GetConfiguredClient(); - } - - /// - /// Tests the retrieval of an account. - /// - /// - [Test] - public async Task GetAccount() - { - var accountResponse = await _apiClient.AccountsEndpoint.GetAccount(_accountId); - TestExtensions.AssertNordigenApiResponseIsSuccessful(accountResponse, HttpStatusCode.OK); - var account = accountResponse.Result!; - Assert.Multiple(() => - { - Assert.That(account.InstitutionId, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); - Assert.That(account.Iban, Is.EqualTo("GL2010440000010445")); - Assert.That(account.Status, Is.EqualTo(BankAccountStatus.Ready)); - }); - } - - /// - /// Tests the retrieval of account balances. - /// - /// - [Test] - public async Task GetBalances() - { - var balancesResponse = await _apiClient.AccountsEndpoint.GetBalances(_accountId); - TestExtensions.AssertNordigenApiResponseIsSuccessful(balancesResponse, HttpStatusCode.OK); - var balances = balancesResponse.Result!; - Assert.Multiple(() => - { - Assert.That(balances, Has.Count.EqualTo(2)); - Assert.That(balances.Any(balance => balance.BalanceAmount.Amount == (decimal) 1913.12), Is.True); - Assert.That(balances.Any(balance => balance.BalanceAmount.Currency == "EUR"), Is.True); - Assert.That(balances.All(balance => balance.BalanceType != BalanceType.Undefined)); - }); - } - - /// - /// Tests the retrieval of account details. - /// - /// - [Test] - public async Task GetAccountDetails() - { - var detailsResponse = await _apiClient.AccountsEndpoint.GetAccountDetails(_accountId); - TestExtensions.AssertNordigenApiResponseIsSuccessful(detailsResponse, HttpStatusCode.OK); - var details = detailsResponse.Result!; - Assert.Multiple(() => - { - Assert.That(details.Iban, Is.EqualTo("GL2010440000010445")); - Assert.That(details.Name, Is.EqualTo("Main Account")); - Assert.That(details.OwnerName, Is.EqualTo("Jane Doe")); - Assert.That(details.CashAccountType, Is.EqualTo(CashAccountType.Current)); - }); - } - - /// - /// Tests the retrieval of transactions. - /// - /// - [Test] - public async Task GetTransactions() - { - var transactionsResponse = await _apiClient.AccountsEndpoint.GetTransactions(_accountId); - TestExtensions.AssertNordigenApiResponseIsSuccessful(transactionsResponse, HttpStatusCode.OK); - var transactions = transactionsResponse.Result!; - Assert.Multiple(() => - { - Assert.That(transactions.BookedTransactions.Any(t => - { - var matchesAll = true; - matchesAll &= t.BankTransactionCode == "PMNT"; - matchesAll &= t.DebtorAccount?.Iban == "GL2010440000010445"; - matchesAll &= t.DebtorName == "MON MOTHMA"; - matchesAll &= t.RemittanceInformationUnstructured == - "For the support of Restoration of the Republic foundation"; - matchesAll &= t.TransactionAmount.Amount == (decimal) 45.00; - matchesAll &= t.TransactionAmount.Currency == "EUR"; - return matchesAll; - })); - Assert.That(transactions.PendingTransactions, Has.Count.GreaterThanOrEqualTo(1)); - }); - } - - /// - /// Tests the retrieval of transactions within a specific time frame. - /// - /// - [Test] - public async Task GetTransactionRange() - { -#if NET6_0_OR_GREATER - var startDate = new DateOnly(2022, 08, 04); - var balancesResponse = - await _apiClient.AccountsEndpoint.GetTransactions(_accountId, startDate, DateOnly.FromDateTime(DateTime.Now.Subtract(TimeSpan.FromHours(24)))); -#else - var startDate = new DateTime(2022, 08, 04); - var balancesResponse = await _apiClient.AccountsEndpoint.GetTransactions(_accountId, startDate, - DateTime.Now.Subtract(TimeSpan.FromMinutes(1))); -#endif - - TestExtensions.AssertNordigenApiResponseIsSuccessful(balancesResponse, HttpStatusCode.OK); - Assert.That(balancesResponse.Result!.BookedTransactions, Has.Count.AtLeast(6)); - } - - /// - /// Tests the retrieval of transactions within a specific time frame in the future. This should return an error. - /// - /// - [Test] - public async Task GetTransactionRangeInFuture() - { - var dateInFuture = DateTime.Now.AddDays(1); -#if NET6_0_OR_GREATER - var balancesResponse = - await _apiClient.AccountsEndpoint.GetTransactions(_accountId, DateOnly.FromDateTime(dateInFuture), DateOnly.FromDateTime(dateInFuture.AddDays(1))); -#else - var balancesResponse = - await _apiClient.AccountsEndpoint.GetTransactions(_accountId, dateInFuture, dateInFuture.AddDays(1)); -#endif - TestExtensions.AssertNordigenApiResponseIsUnsuccessful(balancesResponse, HttpStatusCode.BadRequest); - Assert.Multiple(() => - { - Assert.That(balancesResponse.Error!.StartDateError, Is.Not.Null); - Assert.That(balancesResponse.Error!.EndDateError, Is.Not.Null); - }); - } -} diff --git a/src/RobinTTY.NordigenApiClient.Tests/LiveApi/CredentialTests.cs b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/CredentialTests.cs new file mode 100644 index 0000000..c81651d --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/CredentialTests.cs @@ -0,0 +1,106 @@ +using RobinTTY.NordigenApiClient.Models; +using RobinTTY.NordigenApiClient.Models.Jwt; +using RobinTTY.NordigenApiClient.Tests.Shared; + +namespace RobinTTY.NordigenApiClient.Tests.LiveApi; + +public class CredentialTests +{ + private NordigenClient _apiClient = null!; + + [OneTimeSetUp] + public void Setup() + { + _apiClient = TestHelpers.GetConfiguredClient(); + } + + #region RequestsWithSuccessfulResponse + + /// + /// Tests that is populated after the first authenticated request is made. + /// + [Test] + public async Task CheckValidTokensAfterRequest() + { + Assert.That(_apiClient.JsonWebTokenPair, Is.Null); + + await _apiClient.RequisitionsEndpoint.GetRequisitions(5, 0, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(_apiClient.JsonWebTokenPair, Is.Not.Null); + Assert.That(_apiClient.JsonWebTokenPair!.AccessToken.EncodedToken, Has.Length.GreaterThan(0)); + Assert.That(_apiClient.JsonWebTokenPair!.RefreshToken.EncodedToken, Has.Length.GreaterThan(0)); + }); + } + + #endregion + + #region RequestsWithErrors + + /// + /// Tests the failure of authentication due to invalid credentials when trying to execute a request. + /// + [Test] + public async Task ExecuteRequestWithInvalidCredentials() + { + using var httpClient = new HttpClient(); + var invalidCredentials = new NordigenClientCredentials("01234567-89ab-cdef-0123-456789abcdef", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + var apiClient = new NordigenClient(httpClient, invalidCredentials); + + var agreementsResponse = await apiClient.TokenEndpoint.GetTokenPair(); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(agreementsResponse, HttpStatusCode.Unauthorized); + AssertionHelpers.AssertBasicResponseMatchesExpectations(agreementsResponse.Error, "Authentication failed", + "No active account found with the given credentials"); + }); + } + + /// + /// Tests the failure of authentication due to an invalid token when trying to execute a request. + /// + [Test] + public async Task ExecuteRequestWithUnauthorizedToken() + { + using var httpClient = new HttpClient(); + var invalidCredentials = new NordigenClientCredentials("01234567-89ab-cdef-0123-456789abcdef", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + var token = new JsonWebTokenPair(TestHelpers.Secrets[14], TestHelpers.Secrets[14]); + var apiClient = new NordigenClient(httpClient, invalidCredentials, token); + + var response = await apiClient.InstitutionsEndpoint.GetInstitutions(); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.Unauthorized); + AssertionHelpers.AssertBasicResponseMatchesExpectations(response.Error, "Invalid token", + "Token is invalid or expired"); + }); + } + + /// + /// Tries to execute a request using credentials that haven't whitelisted the used IP. This should cause an error. + /// + [Test] + public async Task ExecuteRequestWithUnauthorizedIp() + { + using var httpClient = new HttpClient(); + var credentials = new NordigenClientCredentials(TestHelpers.Secrets[11], TestHelpers.Secrets[12]); + var apiClient = new NordigenClient(httpClient, credentials); + + var externalIp = await httpClient.GetStringAsync("https://ipinfo.io/ip"); + var response = await apiClient.RequisitionsEndpoint.GetRequisitions(5, 0, CancellationToken.None); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.Forbidden); + AssertionHelpers.AssertBasicResponseMatchesExpectations(response.Error, "IP address access denied", + $"Your IP {externalIp} isn't whitelisted to perform this action"); + }); + } + + #endregion +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/AccountsEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/AccountsEndpointTests.cs new file mode 100644 index 0000000..273b4ac --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/AccountsEndpointTests.cs @@ -0,0 +1,233 @@ +using RobinTTY.NordigenApiClient.Models.Responses; +using RobinTTY.NordigenApiClient.Tests.Shared; + +namespace RobinTTY.NordigenApiClient.Tests.LiveApi.Endpoints; + +public class AccountsEndpointTests +{ + private Guid _accountId; + private Guid _nonExistingAccountId; + private const string InvalidGuid = "abcdefg"; + private NordigenClient _apiClient = null!; + + [OneTimeSetUp] + public void Setup() + { + _accountId = Guid.Parse(TestHelpers.Secrets[9]); + _nonExistingAccountId = Guid.Parse("f1d53c46-260d-4556-82df-4e5fed58e37c"); + _apiClient = TestHelpers.GetConfiguredClient(); + } + + #region RequestsWithSuccessfulResponse + + /// + /// Tests the retrieval of an account. + /// + [Test] + public async Task GetAccount() + { + var accountResponse = await _apiClient.AccountsEndpoint.GetAccount(_accountId); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(accountResponse, HttpStatusCode.OK); + var account = accountResponse.Result!; + Assert.Multiple(() => + { + Assert.That(account.InstitutionId, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); + Assert.That(account.Iban, Is.EqualTo("GL2010440000010445")); + Assert.That(account.Status, Is.EqualTo(BankAccountStatus.Ready)); + }); + } + + /// + /// Tests the retrieval of account balances. + /// + [Test] + public async Task GetBalances() + { + var balancesResponse = await _apiClient.AccountsEndpoint.GetBalances(_accountId); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(balancesResponse, HttpStatusCode.OK); + var balances = balancesResponse.Result!; + Assert.Multiple(() => + { + Assert.That(balances, Has.Count.EqualTo(2)); + Assert.That(balances.Any(balance => balance.BalanceAmount.Amount == (decimal) 1913.12), Is.True); + Assert.That(balances.Any(balance => balance.BalanceAmount.Currency == "EUR"), Is.True); + Assert.That(balances.All(balance => balance.BalanceType != BalanceType.Undefined)); + }); + } + + /// + /// Tests the retrieval of account details. + /// + [Test] + public async Task GetAccountDetails() + { + var detailsResponse = await _apiClient.AccountsEndpoint.GetAccountDetails(_accountId); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(detailsResponse, HttpStatusCode.OK); + var details = detailsResponse.Result!; + Assert.Multiple(() => + { + Assert.That(details.Iban, Is.EqualTo("GL2010440000010445")); + Assert.That(details.Name, Is.EqualTo("Main Account")); + Assert.That(details.OwnerName, Is.EqualTo("Jane Doe")); + Assert.That(details.CashAccountType, Is.EqualTo(CashAccountType.Current)); + }); + } + + /// + /// Tests the retrieval of transactions. + /// + [Test] + public async Task GetTransactions() + { + var transactionsResponse = await _apiClient.AccountsEndpoint.GetTransactions(_accountId); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(transactionsResponse, HttpStatusCode.OK); + var transactions = transactionsResponse.Result!; + Assert.Multiple(() => + { + Assert.That(transactions.BookedTransactions.Any(t => + { + var matchesAll = true; + matchesAll &= t.BankTransactionCode == "PMNT"; + matchesAll &= t.DebtorAccount?.Iban == "GL2010440000010445"; + matchesAll &= t.DebtorName == "MON MOTHMA"; + matchesAll &= t.RemittanceInformationUnstructured == + "For the support of Restoration of the Republic foundation"; + matchesAll &= t.TransactionAmount.Amount == (decimal) 45.00; + matchesAll &= t.TransactionAmount.Currency == "EUR"; + return matchesAll; + })); + Assert.That(transactions.PendingTransactions, Has.Count.GreaterThanOrEqualTo(1)); + }); + } + + /// + /// Tests the retrieval of transactions within a specific time frame. + /// + [Test] + public async Task GetTransactionRange() + { +#if NET6_0_OR_GREATER + var startDate = new DateOnly(2022, 08, 04); + var balancesResponse = + await _apiClient.AccountsEndpoint.GetTransactions(_accountId, startDate, + DateOnly.FromDateTime(DateTime.Now.Subtract(TimeSpan.FromHours(24)))); +#else + var startDate = new DateTime(2022, 08, 04); + var balancesResponse = await _apiClient.AccountsEndpoint.GetTransactions(_accountId, startDate, + DateTime.Now.Subtract(TimeSpan.FromDays(1))); +#endif + + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(balancesResponse, HttpStatusCode.OK); + Assert.That(balancesResponse.Result!.BookedTransactions, Has.Count.AtLeast(6)); + } + + #endregion + + #region RequestsWithErrors + + /// + /// Tests the retrieval of an account that does not exist. This should return an error. + /// + [Test] + public async Task GetAccountWithInvalidGuid() + { + var accountResponse = await _apiClient.AccountsEndpoint.GetAccount(InvalidGuid); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(accountResponse, HttpStatusCode.BadRequest); + AssertionHelpers.AssertBasicResponseMatchesExpectations(accountResponse.Error, "Invalid Account ID", $"{InvalidGuid} is not a valid Account UUID. "); + }); + } + + /// + /// Tests the retrieval of an account that does not exist. This should return an error. + /// + [Test] + public async Task GetAccountThatDoesNotExist() + { + var accountResponse = await _apiClient.AccountsEndpoint.GetAccount(_nonExistingAccountId); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(accountResponse, HttpStatusCode.NotFound); + AssertionHelpers.AssertBasicResponseMatchesExpectations(accountResponse.Error, "Not found.", "Not found."); + }); + } + + /// + /// Tests the retrieval of balances of an account that does not exist. This should return an error. + /// + [Test] + public async Task GetBalancesForAccountThatDoesNotExist() + { + var balancesResponse = await _apiClient.AccountsEndpoint.GetBalances(_nonExistingAccountId); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(balancesResponse, HttpStatusCode.NotFound); + AssertionHelpers.AssertBasicResponseMatchesExpectations(balancesResponse.Error, $"Account ID {_nonExistingAccountId} not found", "Please check whether you specified a valid Account ID"); + }); + } + + /// + /// Tests the retrieval of transactions within a specific time frame in the future. This should return an error. + /// + [Test] + public async Task GetTransactionRangeInFuture() + { + var startDate = DateTime.Today.AddDays(1); + var endDate = DateTime.Today.AddMonths(1).AddDays(1); + + // Returns AccountsError +#if NET6_0_OR_GREATER + var transactionsResponse = await _apiClient.AccountsEndpoint.GetTransactions(TestHelpers.Secrets[9], + DateOnly.FromDateTime(startDate), DateOnly.FromDateTime(endDate)); +#else + var transactionsResponse = await _apiClient.AccountsEndpoint.GetTransactions(TestHelpers.Secrets[9], + startDate, endDate); +#endif + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(transactionsResponse, HttpStatusCode.BadRequest); + Assert.That(transactionsResponse.Error?.StartDateError, Is.Not.Null); + Assert.That(transactionsResponse.Error?.EndDateError, Is.Not.Null); + AssertionHelpers.AssertBasicResponseMatchesExpectations(transactionsResponse.Error?.StartDateError, + "Date can't be in future", + $"'{startDate:yyyy-MM-dd}' can't be greater than {DateTime.Today:yyyy-MM-dd}. Specify correct date range"); + AssertionHelpers.AssertBasicResponseMatchesExpectations(transactionsResponse.Error?.EndDateError, + "Date can't be in future", + $"'{endDate:yyyy-MM-dd}' can't be greater than {DateTime.Today:yyyy-MM-dd}. Specify correct date range"); + }); + } + + /// + /// Tests the retrieval of transactions within a specific time frame where the date range is incorrect, since the endDate is before the startDate. This should throw an exception. + /// + [Test] + public void GetTransactionRangeWithIncorrectRange() + { + var startDate = DateTime.Now.AddMonths(-1); + var endDateBeforeStartDate = startDate.AddDays(-1); + +#if NET6_0_OR_GREATER + var exception = Assert.ThrowsAsync(async () => + await _apiClient.AccountsEndpoint.GetTransactions(_accountId, DateOnly.FromDateTime(startDate), + DateOnly.FromDateTime(endDateBeforeStartDate))); + + Assert.That(exception.Message, + Is.EqualTo( + $"Starting date '{DateOnly.FromDateTime(startDate)}' is greater than end date '{DateOnly.FromDateTime(endDateBeforeStartDate)}'. When specifying date range, starting date must precede the end date.")); +#else + var exception = Assert.ThrowsAsync(async () => + await _apiClient.AccountsEndpoint.GetTransactions(_accountId, startDate, endDateBeforeStartDate)); + + Assert.That(exception.Message, + Is.EqualTo( + $"Starting date '{startDate}' is greater than end date '{endDateBeforeStartDate}'. When specifying date range, starting date must precede the end date.")); +#endif + } + + #endregion +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Endpoints/AgreementsEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/AgreementsEndpointTests.cs similarity index 55% rename from src/RobinTTY.NordigenApiClient.Tests/Endpoints/AgreementsEndpointTests.cs rename to src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/AgreementsEndpointTests.cs index 77a0e50..6140529 100644 --- a/src/RobinTTY.NordigenApiClient.Tests/Endpoints/AgreementsEndpointTests.cs +++ b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/AgreementsEndpointTests.cs @@ -1,35 +1,35 @@ -using System.Net; -using RobinTTY.NordigenApiClient.Models.Errors; -using RobinTTY.NordigenApiClient.Models.Requests; +using RobinTTY.NordigenApiClient.Models.Requests; using RobinTTY.NordigenApiClient.Models.Responses; +using RobinTTY.NordigenApiClient.Tests.Shared; -namespace RobinTTY.NordigenApiClient.Tests.Endpoints; +namespace RobinTTY.NordigenApiClient.Tests.LiveApi.Endpoints; -internal class AgreementsEndpointTests +public class AgreementsEndpointTests { private NordigenClient _apiClient = null!; [OneTimeSetUp] public void Setup() { - _apiClient = TestExtensions.GetConfiguredClient(); + _apiClient = TestHelpers.GetConfiguredClient(); } + #region RequestsWithSuccessfulResponse + /// /// Tests the paging mechanism of retrieving end user agreements. /// Creates 3 agreements, retrieves them using 3 s and deletes the agreements after. /// - /// [Test] public async Task GetAgreementsPaged() { // Get existing agreements var existingAgreements = await _apiClient.AgreementsEndpoint.GetAgreements(100, 0); - TestExtensions.AssertNordigenApiResponseIsSuccessful(existingAgreements, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(existingAgreements, HttpStatusCode.OK); // Create 3 example agreements var agreementRequest = new CreateAgreementRequest(90, 90, - new List {"balances", "details", "transactions"}, "SANDBOXFINANCE_SFIN0000"); + ["balances", "details", "transactions"], "SANDBOXFINANCE_SFIN0000"); var ids = new List(); var existingIds = existingAgreements.Result!.Results.Select(agreement => agreement.Id.ToString()).ToList(); @@ -37,21 +37,21 @@ public async Task GetAgreementsPaged() for (var i = 0; i < 3; i++) { var createResponse = await _apiClient.AgreementsEndpoint.CreateAgreement(agreementRequest); - TestExtensions.AssertNordigenApiResponseIsSuccessful(createResponse, HttpStatusCode.Created); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(createResponse, HttpStatusCode.Created); ids.Add(createResponse.Result!.Id.ToString()); } // Get a response page for each agreement var page1Response = await _apiClient.AgreementsEndpoint.GetAgreements(1, 0); - AssertThatAgreementPageContainsAgreement(page1Response, ids); + AssertionHelpers.AssertThatAgreementPageContainsAgreement(page1Response, ids); var page2Response = await page1Response.Result!.GetNextPage(_apiClient); Assert.That(page2Response, Is.Not.Null); - AssertThatAgreementPageContainsAgreement(page2Response!, ids); + AssertionHelpers.AssertThatAgreementPageContainsAgreement(page2Response!, ids); var page3Response = await page2Response!.Result!.GetNextPage(_apiClient); Assert.That(page3Response, Is.Not.Null); - AssertThatAgreementPageContainsAgreement(page3Response!, ids); + AssertionHelpers.AssertThatAgreementPageContainsAgreement(page3Response!, ids); // On the last page there should be a Url to the previous one Assert.That(page3Response!.Result!.Previous, Is.Not.Null); @@ -60,7 +60,7 @@ public async Task GetAgreementsPaged() var previousPageResponse = await page3Response.Result!.GetPreviousPage(_apiClient); Assert.That(previousPageResponse, Is.Not.Null); - AssertThatAgreementPageContainsAgreement(previousPageResponse!, ids); + AssertionHelpers.AssertThatAgreementPageContainsAgreement(previousPageResponse!, ids); // The previous page agreement id should equal page 2 agreement id var prevAgreementId = previousPageResponse!.Result!.Results.First().Id; @@ -72,61 +72,46 @@ public async Task GetAgreementsPaged() foreach (var id in ids) { var result = await _apiClient.AgreementsEndpoint.DeleteAgreement(id); - TestExtensions.AssertNordigenApiResponseIsSuccessful(result, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(result, HttpStatusCode.OK); } } /// /// Tests the retrieval of one agreement via and string id. /// - /// [Test] public async Task GetAgreement() { // Create agreement var agreementRequest = new CreateAgreementRequest(90, 90, - new List {"balances", "details", "transactions"}, "SANDBOXFINANCE_SFIN0000"); + ["balances", "details", "transactions"], "SANDBOXFINANCE_SFIN0000"); var createResponse = await _apiClient.AgreementsEndpoint.CreateAgreement(agreementRequest); - TestExtensions.AssertNordigenApiResponseIsSuccessful(createResponse, HttpStatusCode.Created); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(createResponse, HttpStatusCode.Created); var id = createResponse.Result!.Id; // Get agreement via guid and string id, should retrieve the same agreement var agreementResponseGuid = await _apiClient.AgreementsEndpoint.GetAgreement(id); var agreementResponseString = await _apiClient.AgreementsEndpoint.GetAgreement(id.ToString()); - TestExtensions.AssertNordigenApiResponseIsSuccessful(agreementResponseGuid, HttpStatusCode.OK); - TestExtensions.AssertNordigenApiResponseIsSuccessful(agreementResponseString, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(agreementResponseGuid, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(agreementResponseString, HttpStatusCode.OK); Assert.That(agreementResponseGuid.Result!.Id, Is.EqualTo(agreementResponseString.Result!.Id)); // Delete agreement var deleteResponse = await _apiClient.AgreementsEndpoint.DeleteAgreement(id); - TestExtensions.AssertNordigenApiResponseIsSuccessful(deleteResponse, HttpStatusCode.OK); - } - - /// - /// Tests the retrieval of an agreement with an invalid guid. - /// - /// - [Test] - public async Task GetAgreementWithInvalidGuid() - { - const string guid = "f84d7b8-dee4-4cd9-bc6d-842ef78f6028"; - var response = await _apiClient.AgreementsEndpoint.GetAgreement(guid); - TestExtensions.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.NotFound); - Assert.That(response.Error!.Detail, Is.EqualTo("Not found.")); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(deleteResponse, HttpStatusCode.OK); } /// /// Tests the creation and deletion of an end user agreement. /// - /// [Test] public async Task CreateAcceptAndDeleteAgreement() { // Create the agreement - var agreement = new CreateAgreementRequest(90, 90, new List {"balances", "details", "transactions"}, + var agreement = new CreateAgreementRequest(90, 90, ["balances", "details", "transactions"], "SANDBOXFINANCE_SFIN0000"); var response = await _apiClient.AgreementsEndpoint.CreateAgreement(agreement); - TestExtensions.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.Created); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.Created); var result = response.Result!; Assert.Multiple(() => @@ -142,53 +127,100 @@ public async Task CreateAcceptAndDeleteAgreement() // Accept the agreement (should fail) var acceptMetadata = new AcceptAgreementRequest("example_user_agent", "192.168.178.1"); var acceptResponse = await _apiClient.AgreementsEndpoint.AcceptAgreement(response.Result!.Id, acceptMetadata); - TestExtensions.AssertNordigenApiResponseIsUnsuccessful(acceptResponse, HttpStatusCode.Forbidden); + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(acceptResponse, HttpStatusCode.Forbidden); Assert.That(acceptResponse.Error!.Detail, Is.EqualTo( "Your company doesn't have permission to accept EUA. You'll have to use our default form for this action.")); // Delete the agreement var deletionResponse = await _apiClient.AgreementsEndpoint.DeleteAgreement(result.Id); - TestExtensions.AssertNordigenApiResponseIsSuccessful(deletionResponse, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(deletionResponse, HttpStatusCode.OK); Assert.That(deletionResponse.Result!.Summary, Is.EqualTo("End User Agreement deleted")); } + #endregion + + #region RequestsWithErrors + + /// + /// Tests the retrieval of an agreement with an invalid guid. + /// + [Test] + public async Task GetAgreementWithInvalidGuid() + { + const string guid = "f84d7b8-dee4-4cd9-bc6d-842ef78f6028"; + + var response = await _apiClient.AgreementsEndpoint.GetAgreement(guid); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + AssertionHelpers.AssertBasicResponseMatchesExpectations(response.Error, "Invalid EndUserAgreement ID", + $"{guid} is not a valid EndUserAgreement UUID. "); + }); + } + /// /// Tests the creation of an end user agreement with an invalid institution id. /// - /// [Test] public async Task CreateAgreementWithInvalidInstitutionId() { - var agreement = new CreateAgreementRequest(90, 90, new List {"balances", "details", "transactions"}, - "SANDBOXFINANCE_SFIN000"); + var agreement = new CreateAgreementRequest(90, 90, + ["balances", "details", "transactions"], "invalid_institution"); + var response = await _apiClient.AgreementsEndpoint.CreateAgreement(agreement); - TestExtensions.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); - var result = response.Error!; - Assert.That(result.InstitutionIdError, Is.Not.Null); - Assert.That(result.InstitutionIdError!.Detail, - Is.EqualTo("Get Institution IDs from /institutions/?country={$COUNTRY_CODE}")); + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + Assert.Multiple(() => + { + Assert.That(response.Error!.InstitutionIdError, Is.Not.Null); + Assert.That(response.Error!.InstitutionIdError!.Summary, + Is.EqualTo("Unknown Institution ID invalid_institution")); + Assert.That(response.Error!.InstitutionIdError!.Detail, + Is.EqualTo("Get Institution IDs from /institutions/?country={$COUNTRY_CODE}")); + }); + } + + /// + /// Tests the creation of an end user agreement with an empty institution id and empty access scopes. + /// + [Test] + public async Task CreateAgreementWithEmptyInstitutionIdAndAccessScopes() + { + var agreement = new CreateAgreementRequest(90, 90, null!, null!); + + var response = await _apiClient.AgreementsEndpoint.CreateAgreement(agreement); + + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + Assert.Multiple(() => + { + Assert.That(response.Error!.InstitutionIdError, Is.Not.Null); + Assert.That(response.Error!.InstitutionIdError!.Summary, Is.EqualTo("This field may not be null.")); + Assert.That(response.Error!.InstitutionIdError!.Detail, Is.EqualTo("This field may not be null.")); + + Assert.That(response.Error!.AccessScopeError!.Summary, Is.EqualTo("This field may not be null.")); + Assert.That(response.Error!.AccessScopeError!.Detail, Is.EqualTo("This field may not be null.")); + + }); } /// /// Tests the creation of an end user agreement with invalid parameters. /// - /// [Test] public async Task CreateAgreementWithInvalidParams() { var agreement = new CreateAgreementRequest(200, 200, - new List {"balances", "details", "transactions", "invalid", "invalid2"}, "SANDBOXFINANCE_SFIN0000"); - var response = await _apiClient.AgreementsEndpoint.CreateAgreement(agreement); - TestExtensions.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + ["balances", "details", "transactions", "invalid", "invalid2"], "SANDBOXFINANCE_SFIN0000"); + var response = await _apiClient.AgreementsEndpoint.CreateAgreement(agreement); var result = response.Error!; + Assert.Multiple(() => { - Assert.That( - new[] {result.InstitutionIdError, result.AgreementError}, - Has.All.Null); + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + Assert.That(new[] {result.InstitutionIdError, result.AgreementError}, Has.All.Null); Assert.That(result.AccessScopeError!.Detail, Is.EqualTo("Choose one or several from ['balances', 'details', 'transactions']")); Assert.That(result.AccessValidForDaysError!.Detail, @@ -199,16 +231,23 @@ public async Task CreateAgreementWithInvalidParams() }); } - private static void AssertThatAgreementPageContainsAgreement( - NordigenApiResponse, BasicError> pagedResponse, List ids) + [Test] + public async Task CreateAgreementWithInvalidParamsAtPolishInstitution() { - TestExtensions.AssertNordigenApiResponseIsSuccessful(pagedResponse, HttpStatusCode.OK); - var page2Result = pagedResponse.Result!; - var page2Agreements = page2Result.Results.ToList(); + var agreement = new CreateAgreementRequest(90, 90, + ["balances", "transactions"], "PKO_BPKOPLPW"); + + var response = await _apiClient.AgreementsEndpoint.CreateAgreement(agreement); + Assert.Multiple(() => { - Assert.That(page2Agreements, Has.Count.EqualTo(1)); - Assert.That(ids, Does.Contain(page2Agreements.First().Id.ToString())); + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + Assert.That(new[] {response.Error!.InstitutionIdError, response.Error!.AgreementError}, Has.All.Null); + Assert.That(response.Error!.Detail, + Is.EqualTo("For this institution the following scopes are required together: ['details', 'balances']")); + Assert.That(response.Error!.Summary, Is.EqualTo("Institution access scope dependencies error")); }); } + + #endregion } diff --git a/src/RobinTTY.NordigenApiClient.Tests/Endpoints/InstitutionsEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/InstitutionsEndpointTests.cs similarity index 61% rename from src/RobinTTY.NordigenApiClient.Tests/Endpoints/InstitutionsEndpointTests.cs rename to src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/InstitutionsEndpointTests.cs index 162abf5..4fbfb72 100644 --- a/src/RobinTTY.NordigenApiClient.Tests/Endpoints/InstitutionsEndpointTests.cs +++ b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/InstitutionsEndpointTests.cs @@ -1,28 +1,29 @@ -using System.Net; +using RobinTTY.NordigenApiClient.Tests.Shared; -namespace RobinTTY.NordigenApiClient.Tests.Endpoints; +namespace RobinTTY.NordigenApiClient.Tests.LiveApi.Endpoints; -internal class InstitutionsEndpointTests +public class InstitutionsEndpointTests { private NordigenClient _apiClient = null!; [OneTimeSetUp] public void Setup() { - _apiClient = TestExtensions.GetConfiguredClient(); + _apiClient = TestHelpers.GetConfiguredClient(); } + #region RequestsWithSuccessfulResponse + /// /// Tests the retrieving of institutions for all countries and a specific country (Great Britain). /// - /// [Test] public async Task GetInstitutions() { var response = await _apiClient.InstitutionsEndpoint.GetInstitutions(); - TestExtensions.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.OK); var response2 = await _apiClient.InstitutionsEndpoint.GetInstitutions("GB"); - TestExtensions.AssertNordigenApiResponseIsSuccessful(response2, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(response2, HttpStatusCode.OK); var result = response.Result!.ToList(); var result2 = response2.Result!.ToList(); @@ -38,24 +39,23 @@ public async Task GetInstitutions() /// /// Tests the retrieving of institutions with various query parameters set. /// - /// [Test] public async Task GetInstitutionsWithFlags() { var allFlagsSetTrue = await _apiClient.InstitutionsEndpoint.GetInstitutions("GB", true, true, true, true, true, true, true, true, true, true, true); - TestExtensions.AssertNordigenApiResponseIsSuccessful(allFlagsSetTrue, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(allFlagsSetTrue, HttpStatusCode.OK); var allFlagsSetFalse = await _apiClient.InstitutionsEndpoint.GetInstitutions("GB", false, false, false, false, false, false, false, false, false, false, false); - TestExtensions.AssertNordigenApiResponseIsSuccessful(allFlagsSetFalse, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(allFlagsSetFalse, HttpStatusCode.OK); var institutionsWithAccountSelection = await _apiClient.InstitutionsEndpoint.GetInstitutions(accountSelectionSupported: true); - TestExtensions.AssertNordigenApiResponseIsSuccessful(institutionsWithAccountSelection, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(institutionsWithAccountSelection, HttpStatusCode.OK); var institutionsWithoutAccountSelection = await _apiClient.InstitutionsEndpoint.GetInstitutions(accountSelectionSupported: false); - TestExtensions.AssertNordigenApiResponseIsSuccessful(institutionsWithoutAccountSelection, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(institutionsWithoutAccountSelection, HttpStatusCode.OK); var allInstitutions = await _apiClient.InstitutionsEndpoint.GetInstitutions(); - TestExtensions.AssertNordigenApiResponseIsSuccessful(allInstitutions, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(allInstitutions, HttpStatusCode.OK); var allFlagsTrueResult = allFlagsSetTrue.Result!.ToList(); var withAccountSelectionResult = institutionsWithAccountSelection.Result!.ToList(); @@ -73,40 +73,59 @@ public async Task GetInstitutionsWithFlags() }); } + /// + /// Tests the retrieving of a specific institution. + /// + [Test] + public async Task GetInstitution() + { + var response = await _apiClient.InstitutionsEndpoint.GetInstitution("SANDBOXFINANCE_SFIN0000"); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.OK); + Assert.That(response.Result!.Bic, Is.EqualTo("SFIN0000")); + Assert.That(response.Result!.Id, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); + Assert.That(response.Result!.Name, Is.EqualTo("Sandbox Finance")); + Assert.That(response.Result!.TransactionTotalDays, Is.EqualTo(90)); + }); + } + + #endregion + + #region RequestsWithErrors + /// /// Tests the retrieving of institutions for a country which is not covered by the API. /// - /// [Test] public async Task GetInstitutionsForNotCoveredCountry() { var response = await _apiClient.InstitutionsEndpoint.GetInstitutions("US"); - TestExtensions.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); Assert.Multiple(() => { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); Assert.That(response.Error!.Detail, Is.EqualTo("US is not a valid choice.")); Assert.That(response.Error!.Summary, Is.EqualTo("Invalid country choice.")); }); } /// - /// Tests the retrieving of a specific institution. + /// Tests the retrieving of an institution with an invalid id. /// - /// [Test] - public async Task GetInstitution() + public async Task GetNonExistingInstitution() { - var response = await _apiClient.InstitutionsEndpoint.GetInstitution("SANDBOXFINANCE_SFIN0000"); - TestExtensions.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.OK); - - var result = response.Result!; + var response = await _apiClient.InstitutionsEndpoint.GetInstitution("invalid_id"); + Assert.Multiple(() => { - Assert.That(result.Bic, Is.EqualTo("SFIN0000")); - Assert.That(result.Id, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); - Assert.That(result.Name, Is.EqualTo("Sandbox Finance")); - Assert.That(result.TransactionTotalDays, Is.EqualTo(90)); + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.NotFound); + Assert.That(response.Error!.Detail, Is.EqualTo("Not found.")); + Assert.That(response.Error!.Summary, Is.EqualTo("Not found.")); }); } + + #endregion } diff --git a/src/RobinTTY.NordigenApiClient.Tests/Endpoints/RequisitionsEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/RequisitionsEndpointTests.cs similarity index 58% rename from src/RobinTTY.NordigenApiClient.Tests/Endpoints/RequisitionsEndpointTests.cs rename to src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/RequisitionsEndpointTests.cs index 9a2f590..66e64aa 100644 --- a/src/RobinTTY.NordigenApiClient.Tests/Endpoints/RequisitionsEndpointTests.cs +++ b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/RequisitionsEndpointTests.cs @@ -1,20 +1,21 @@ -using System.Net; -using RobinTTY.NordigenApiClient.Models.Errors; -using RobinTTY.NordigenApiClient.Models.Requests; +using RobinTTY.NordigenApiClient.Models.Requests; using RobinTTY.NordigenApiClient.Models.Responses; +using RobinTTY.NordigenApiClient.Tests.Shared; -namespace RobinTTY.NordigenApiClient.Tests.Endpoints; +namespace RobinTTY.NordigenApiClient.Tests.LiveApi.Endpoints; -internal class RequisitionsEndpointTests +public class RequisitionsEndpointTests { private NordigenClient _apiClient = null!; [OneTimeSetUp] public void Setup() { - _apiClient = TestExtensions.GetConfiguredClient(); + _apiClient = TestHelpers.GetConfiguredClient(); } + #region RequestsWithSuccessfulResponse + /// /// Tests the retrieval of all existing requisitions. /// @@ -22,13 +23,14 @@ public void Setup() public async Task GetRequisitions() { var response = await _apiClient.RequisitionsEndpoint.GetRequisitions(100, 0); - TestExtensions.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.OK); var requisitions = response.Result?.Results.ToList(); - Assert.That(requisitions, Is.Not.Null); - Assert.That(requisitions, Has.Count.GreaterThan(0)); + Assert.Multiple(() => { + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.OK); + Assert.That(requisitions, Is.Not.Null); + Assert.That(requisitions, Has.Count.GreaterThan(0)); Assert.That(requisitions!.All(req => req.Status != RequisitionStatus.Undefined)); Assert.That(requisitions, Has.All.Matches(req => req.Id != Guid.Empty)); }); @@ -38,7 +40,6 @@ public async Task GetRequisitions() /// Tests all methods of the requisitions endpoint. /// Creates 3 requisitions, retrieves them using 3 s and deletes the requisitions after. /// - /// [Test] public async Task GetRequisitionsPaged() { @@ -48,12 +49,12 @@ public async Task GetRequisitionsPaged() var agreementRequest = new CreateAgreementRequest(90, 90, new List {"balances", "details", "transactions"}, institutionId); var agreementResponse = await _apiClient.AgreementsEndpoint.CreateAgreement(agreementRequest); - TestExtensions.AssertNordigenApiResponseIsSuccessful(agreementResponse, HttpStatusCode.Created); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(agreementResponse, HttpStatusCode.Created); var agreementId = agreementResponse.Result!.Id; // Get existing requisitions var existingRequisitions = await _apiClient.RequisitionsEndpoint.GetRequisitions(100, 0); - TestExtensions.AssertNordigenApiResponseIsSuccessful(existingRequisitions, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(existingRequisitions, HttpStatusCode.OK); // Create 3 example requisitions var redirect = new Uri("https://github.com/RobinTTY/NordigenApiClient"); @@ -66,7 +67,7 @@ public async Task GetRequisitionsPaged() var requisitionRequest = new CreateRequisitionRequest(redirect, institutionId, $"reference_{i}", "EN", agreementId); var createResponse = await _apiClient.RequisitionsEndpoint.CreateRequisition(requisitionRequest); - TestExtensions.AssertNordigenApiResponseIsSuccessful(createResponse, HttpStatusCode.Created); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(createResponse, HttpStatusCode.Created); ids.Add(createResponse.Result!.Id.ToString()); } @@ -99,40 +100,62 @@ public async Task GetRequisitionsPaged() // Retrieve a single requisition via guid/string id var requisitionResponseGuid = await _apiClient.RequisitionsEndpoint.GetRequisition(page2RequisitionId); - TestExtensions.AssertNordigenApiResponseIsSuccessful(requisitionResponseGuid, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(requisitionResponseGuid, HttpStatusCode.OK); var requisitionResponseString = await _apiClient.RequisitionsEndpoint.GetRequisition(page2RequisitionId.ToString()); - TestExtensions.AssertNordigenApiResponseIsSuccessful(requisitionResponseString, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(requisitionResponseString, HttpStatusCode.OK); Assert.That(requisitionResponseString.Result!.Id, Is.EqualTo(requisitionResponseGuid.Result!.Id)); // Delete created resources var agreementDeletion = await _apiClient.AgreementsEndpoint.DeleteAgreement(agreementId); - TestExtensions.AssertNordigenApiResponseIsSuccessful(agreementDeletion, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(agreementDeletion, HttpStatusCode.OK); existingIds.ForEach(id => ids.Remove(id)); foreach (var id in ids) { var result = await _apiClient.RequisitionsEndpoint.DeleteRequisition(id); - TestExtensions.AssertNordigenApiResponseIsSuccessful(result, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(result, HttpStatusCode.OK); } } + private static void AssertThatRequisitionsPageContainsRequisition( + NordigenApiResponse, BasicResponse> pagedResponse, List ids) + { + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(pagedResponse, HttpStatusCode.OK); + var page2Result = pagedResponse.Result!; + var page2Requisitions = page2Result.Results.ToList(); + + Assert.Multiple(() => + { + Assert.That(page2Requisitions, Has.Count.EqualTo(1)); + Assert.That(ids, Does.Contain(page2Requisitions.First().Id.ToString())); + Assert.That(page2Requisitions.ToList().All(req => req.Status != RequisitionStatus.Undefined)); + }); + } + + #endregion + + #region RequestsWithErrors + /// /// Tests the retrieval of a requisition with an invalid guid. /// - /// [Test] public async Task GetRequisitionWithInvalidGuid() { const string guid = "f84d7b8-dee4-4cd9-bc6d-842ef78f6028"; + var response = await _apiClient.RequisitionsEndpoint.GetRequisition(guid); - TestExtensions.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.NotFound); - Assert.That(response.Error!.Detail, Is.EqualTo("Not found.")); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.NotFound); + Assert.That(response.Error!.Detail, Is.EqualTo("Not found.")); + }); } /// /// Tests the creation of an end user agreement with invalid id. /// - /// [Test] public async Task CreateRequisitionWithInvalidId() { @@ -142,7 +165,7 @@ public async Task CreateRequisitionWithInvalidId() new CreateRequisitionRequest(redirect, "123", "internal_reference", "EN", agreementId, null, true, true); var response = await _apiClient.RequisitionsEndpoint.CreateRequisition(requisitionRequest); - TestExtensions.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); Assert.Multiple(() => { Assert.That(response.Error!.Summary, Is.EqualTo("Invalid ID")); @@ -151,17 +174,50 @@ public async Task CreateRequisitionWithInvalidId() }); } - private static void AssertThatRequisitionsPageContainsRequisition( - NordigenApiResponse, BasicError> pagedResponse, List ids) + /// + /// Tests the creation of an end user agreement with invalid parameters in the . + /// + [Test] + public async Task CreateRequisitionWithInvalidParameters() { - TestExtensions.AssertNordigenApiResponseIsSuccessful(pagedResponse, HttpStatusCode.OK); - var page2Result = pagedResponse.Result!; - var page2Requisitions = page2Result.Results.ToList(); + var redirect = new Uri("ftp://ftp.test.com"); + // Agreement belongs to SANDBOXFINANCE_SFIN0000 + var agreementId = Guid.Parse("f34c3c71-4a62-4a25-b998-3f37ddce84a2"); + var requisitionRequest = + new CreateRequisitionRequest(redirect, "", "", "AB", agreementId, "12345", true, true); + + var response = await _apiClient.RequisitionsEndpoint.CreateRequisition(requisitionRequest); + Assert.Multiple(() => { - Assert.That(page2Requisitions, Has.Count.EqualTo(1)); - Assert.That(ids, Does.Contain(page2Requisitions.First().Id.ToString())); - Assert.That(page2Requisitions.ToList().All(req => req.Status != RequisitionStatus.Undefined)); + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + + Assert.That(response.Error!.AccountSelectionError!.Summary, Is.EqualTo("Account selection not supported")); + Assert.That(response.Error!.AccountSelectionError!.Detail, + Is.EqualTo("Account selection not supported for ")); + + Assert.That(response.Error!.AgreementError!.Summary, Is.EqualTo("Incorrect Institution ID ")); + Assert.That(response.Error!.AgreementError!.Detail, + Is.EqualTo( + "Provided Institution ID: '' for requisition does not match EUA institution ID 'SANDBOXFINANCE_SFIN0000'. Please provide correct institution ID: 'SANDBOXFINANCE_SFIN0000'")); + + Assert.That(response.Error!.InstitutionIdError!.Summary, Is.EqualTo("This field may not be blank.")); + Assert.That(response.Error!.InstitutionIdError!.Detail, Is.EqualTo("This field may not be blank.")); + + Assert.That(response.Error!.InstitutionIdError!.Summary, Is.EqualTo("This field may not be blank.")); + Assert.That(response.Error!.InstitutionIdError!.Detail, Is.EqualTo("This field may not be blank.")); + + Assert.That(response.Error!.SocialSecurityNumberError!.Summary, + Is.EqualTo("SSN verification not supported")); + Assert.That(response.Error!.SocialSecurityNumberError!.Detail, + Is.EqualTo("SSN verification not supported for ")); + + Assert.That(response.Error!.UserLanguageError!.Summary, + Is.EqualTo("Provided user_language is invalid or not supported")); + Assert.That(response.Error!.UserLanguageError!.Detail, + Is.EqualTo("'AB' is an invalid or unsupported language")); }); } + + #endregion } diff --git a/src/RobinTTY.NordigenApiClient.Tests/Endpoints/TokenEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/TokenEndpointTests.cs similarity index 66% rename from src/RobinTTY.NordigenApiClient.Tests/Endpoints/TokenEndpointTests.cs rename to src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/TokenEndpointTests.cs index 5e6d0b5..16d57eb 100644 --- a/src/RobinTTY.NordigenApiClient.Tests/Endpoints/TokenEndpointTests.cs +++ b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/Endpoints/TokenEndpointTests.cs @@ -1,36 +1,56 @@ -using System.Net; -using RobinTTY.NordigenApiClient.Models; +using RobinTTY.NordigenApiClient.Models; using RobinTTY.NordigenApiClient.Models.Jwt; +using RobinTTY.NordigenApiClient.Tests.Shared; -namespace RobinTTY.NordigenApiClient.Tests.Endpoints; +namespace RobinTTY.NordigenApiClient.Tests.LiveApi.Endpoints; -internal class TokenEndpointTests +public class TokenEndpointTests { private NordigenClient _apiClient = null!; [OneTimeSetUp] public void Setup() { - _apiClient = TestExtensions.GetConfiguredClient(); + _apiClient = TestHelpers.GetConfiguredClient(); } + + #region RequestsWithSuccessfulResponse /// /// Tests the retrieving and refreshing of the JWT access tokens. /// - /// [Test] public async Task GetJsonWebTokenPairAndRefresh() { var response = await _apiClient.TokenEndpoint.GetTokenPair(); - TestExtensions.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.OK); var response2 = await _apiClient.TokenEndpoint.RefreshAccessToken(response.Result!.RefreshToken); - TestExtensions.AssertNordigenApiResponseIsSuccessful(response2, HttpStatusCode.OK); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(response2, HttpStatusCode.OK); } + /// + /// Tests using the API with an expired access token. + /// Requires secrets.txt to contain expired access token / valid refresh token pair. + /// + [Test] + public async Task ReuseExpiredToken() + { + var httpClient = new HttpClient(); + var credentials = new NordigenClientCredentials(TestHelpers.Secrets[0], TestHelpers.Secrets[1]); + var tokenPair = new JsonWebTokenPair(TestHelpers.Secrets[6], TestHelpers.Secrets[7]); + var apiClient = new NordigenClient(httpClient, credentials, tokenPair); + + var result = await apiClient.RequisitionsEndpoint.GetRequisitions(10, 0); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(result, HttpStatusCode.OK); + } + + #endregion + + #region RequestsWithErrors + /// /// Tests retrieving a token with invalid credentials. /// - /// [Test] public async Task GetTokenWithInvalidCredentials() { @@ -47,26 +67,6 @@ public async Task GetTokenWithInvalidCredentials() Assert.That(response.Error, Is.Not.Null); }); } - - /// - /// Tests using the API with an expired access token. - /// Requires secrets.txt to contain expired access token / valid refresh token pair. - /// - /// - [Test] - public async Task ReuseExpiredToken() - { -#if NET6_0_OR_GREATER - var secrets = await File.ReadAllLinesAsync("secrets.txt"); -#else - var secrets = File.ReadAllLines("secrets.txt"); -#endif - var httpClient = new HttpClient(); - var credentials = new NordigenClientCredentials(secrets[0], secrets[1]); - var tokenPair = new JsonWebTokenPair(secrets[6], secrets[7]); - var apiClient = new NordigenClient(httpClient, credentials, tokenPair); - - var result = await apiClient.RequisitionsEndpoint.GetRequisitions(10, 0); - TestExtensions.AssertNordigenApiResponseIsSuccessful(result, HttpStatusCode.OK); - } + + #endregion } diff --git a/src/RobinTTY.NordigenApiClient.Tests/LiveApi/NordigenApiClientTests.cs b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/NordigenApiClientTests.cs new file mode 100644 index 0000000..c2ff739 --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/LiveApi/NordigenApiClientTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; +using RobinTTY.NordigenApiClient.Models.Errors; +using RobinTTY.NordigenApiClient.Models.Responses; +using RobinTTY.NordigenApiClient.Tests.Shared; + +namespace RobinTTY.NordigenApiClient.Tests.LiveApi; + +public class NordigenApiClientTests +{ + #region RequestsWithSuccessfulResponse + + /// + /// Executes a request to the Nordigen API using the default base address. + /// + [Test] + public async Task ExecuteRequestWithDefaultBaseAddress() + { + var apiClient = TestHelpers.GetConfiguredClient(); + await ExecuteExampleRequest(apiClient); + } + + /// + /// Executes a request to the Nordigen API using a custom base address. + /// + [Test] + public async Task ExecuteRequestWithCustomBaseAddress() + { + var apiClient = TestHelpers.GetConfiguredClient("https://ob.gocardless.com/api/v2/"); + await ExecuteExampleRequest(apiClient); + } + + private async Task ExecuteExampleRequest(NordigenClient apiClient) + { + var response = await apiClient.TokenEndpoint.GetTokenPair(); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.OK); + var response2 = await apiClient.TokenEndpoint.RefreshAccessToken(response.Result!.RefreshToken); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(response2, HttpStatusCode.OK); + } + + #endregion + + #region RequestsWithErrors + + [Test] + [Ignore("Test executes a lot of requests using the LiveAPI.")] + public async Task ExecuteRequestsUntilRateLimitReached() + { + var apiClient = TestHelpers.GetConfiguredClient(); + NordigenApiResponse, BasicResponse>? unsuccessfulRequest = null; + + while (unsuccessfulRequest is null) + { + var tasks = new ConcurrentBag, BasicResponse>>>(); + Parallel.For(0, 10, _ => + { + var task = apiClient.InstitutionsEndpoint.GetInstitutions("LI"); + tasks.Add(task); + }); + + var results = await Task.WhenAll(tasks); + unsuccessfulRequest = results.FirstOrDefault(result => !result.IsSuccess); + } + +#if NET6_0_OR_GREATER + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(unsuccessfulRequest, HttpStatusCode.TooManyRequests); +#else + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(unsuccessfulRequest, (HttpStatusCode)429); +#endif + Assert.That(unsuccessfulRequest.Error!.Summary, Is.EqualTo("Rate limit exceeded")); + Assert.That(unsuccessfulRequest.Error!.Detail, Does.Match("The rate limit for this resource is [0-9]*\\/\\w*\\. Please try again in [0-9]* \\w*")); + } + + #endregion +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Mocks/CredentialTests.cs b/src/RobinTTY.NordigenApiClient.Tests/Mocks/CredentialTests.cs new file mode 100644 index 0000000..0c46be9 --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Mocks/CredentialTests.cs @@ -0,0 +1,73 @@ +using RobinTTY.NordigenApiClient.Tests.Shared; + +namespace RobinTTY.NordigenApiClient.Tests.Mocks; + +public class CredentialTests +{ + #region RequestsWithSuccessfulResponse + + /// + /// Tests that is populated after the first authenticated request is made. + /// + [Test] + public async Task CheckValidTokensAfterRequest() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.RequisitionsEndpointMockData.GetRequisitions, HttpStatusCode.OK); + Assert.That(apiClient.JsonWebTokenPair, Is.Null); + + await apiClient.RequisitionsEndpoint.GetRequisitions(5, 0, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(apiClient.JsonWebTokenPair, Is.Not.Null); + Assert.That(apiClient.JsonWebTokenPair!.AccessToken.EncodedToken, Has.Length.GreaterThan(0)); + Assert.That(apiClient.JsonWebTokenPair!.RefreshToken.EncodedToken, Has.Length.GreaterThan(0)); + }); + } + + #endregion + + #region RequestsWithErrors + + /// + /// Tests the failure of authentication when trying to execute a request. + /// + [Test] + public async Task ExecuteRequestWithInvalidCredentials() + { + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.CredentialMockData.NoAccountForGivenCredentialsError, + HttpStatusCode.Unauthorized, addDefaultAuthToken: false); + + var tokenPairResponse = await apiClient.TokenEndpoint.GetTokenPair(); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(tokenPairResponse, HttpStatusCode.Unauthorized); + AssertionHelpers.AssertBasicResponseMatchesExpectations(tokenPairResponse.Error, "Authentication failed", + "No active account found with the given credentials"); + }); + } + + /// + /// Tries to execute a request using credentials that haven't whitelisted the used IP. This should cause an error. + /// + [Test] + public async Task ExecuteRequestWithUnauthorizedIp() + { + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.CredentialMockData.IpNotWhitelistedError, + HttpStatusCode.Forbidden); + + var response = await apiClient.RequisitionsEndpoint.GetRequisitions(5, 0, CancellationToken.None); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.Forbidden); + AssertionHelpers.AssertBasicResponseMatchesExpectations(response.Error, "IP address access denied", + $"Your IP 127.0.0.1 isn't whitelisted to perform this action"); + }); + } + + #endregion +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/AccountsEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/AccountsEndpointTests.cs new file mode 100644 index 0000000..06041ec --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/AccountsEndpointTests.cs @@ -0,0 +1,261 @@ +using FakeItEasy; +using RobinTTY.NordigenApiClient.Models.Responses; +using RobinTTY.NordigenApiClient.Tests.Shared; + +namespace RobinTTY.NordigenApiClient.Tests.Mocks.Endpoints; + +public class AccountsEndpointTests +{ + private const string InvalidGuid = "abcdefg"; + + #region RequestsWithSuccessfulResponse + + /// + /// Tests the retrieval of an account. + /// + [Test] + public async Task GetAccount() + { + var apiClient = + TestHelpers.GetMockClient(TestHelpers.MockData.AccountsEndpointMockData.GetAccount, HttpStatusCode.OK); + + var accountResponse = await apiClient.AccountsEndpoint.GetAccount(A.Dummy()); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(accountResponse, HttpStatusCode.OK); + var account = accountResponse.Result!; + Assert.Multiple(() => + { + Assert.That(account.InstitutionId, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); + Assert.That(account.Iban, Is.EqualTo("GL2010440000010445")); + Assert.That(account.Status, Is.EqualTo(BankAccountStatus.Ready)); + }); + } + + /// + /// Tests the retrieval of account balances. + /// + [Test] + public async Task GetBalances() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.AccountsEndpointMockData.GetBalances, + HttpStatusCode.OK); + + var balancesResponse = await apiClient.AccountsEndpoint.GetBalances(A.Dummy()); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(balancesResponse, HttpStatusCode.OK); + var balances = balancesResponse.Result!; + Assert.Multiple(() => + { + Assert.That(balances, Has.Count.EqualTo(2)); + Assert.That(balances.Any(balance => balance.BalanceAmount.Amount == (decimal) 1913.12), Is.True); + Assert.That(balances.Any(balance => balance.BalanceAmount.Currency == "EUR"), Is.True); + Assert.That(balances.All(balance => balance.BalanceType != BalanceType.Undefined)); + }); + } + + /// + /// Tests the retrieval of account details. + /// + [Test] + public async Task GetAccountDetails() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.AccountsEndpointMockData.GetAccountDetails, + HttpStatusCode.OK); + + var detailsResponse = await apiClient.AccountsEndpoint.GetAccountDetails(A.Dummy()); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(detailsResponse, HttpStatusCode.OK); + var details = detailsResponse.Result!; + + Assert.Multiple(() => + { + Assert.That(details.Iban, Is.EqualTo("GL2010440000010445")); + Assert.That(details.Name, Is.EqualTo("Main Account")); + Assert.That(details.Product, Is.EqualTo("Credit Card")); + Assert.That(details.OwnerName, Is.EqualTo("Jane Doe")); + Assert.That(details.ResourceId, Is.EqualTo("abc")); + Assert.That(details.Currency, Is.EqualTo("EUR")); + Assert.That(details.CashAccountType, Is.EqualTo(CashAccountType.Current)); + }); + } + + /// + /// Tests the retrieval of transactions. + /// + [Test] + public async Task GetTransactions() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.AccountsEndpointMockData.GetTransactions, + HttpStatusCode.OK); + + var transactionsResponse = await apiClient.AccountsEndpoint.GetTransactions(A.Dummy()); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(transactionsResponse, HttpStatusCode.OK); + var transactions = transactionsResponse.Result!; + + Assert.Multiple(() => + { + Assert.That(transactions.BookedTransactions.Any(t => + { + var matchesAll = true; + matchesAll &= t.BankTransactionCode == "PMNT"; + matchesAll &= t.DebtorAccount?.Iban == "GL2010440000010445"; + matchesAll &= t.DebtorName == "MON MOTHMA"; + matchesAll &= t.RemittanceInformationUnstructured == + "For the support of Restoration of the Republic foundation"; + matchesAll &= t.TransactionAmount.Amount == (decimal) 45.00; + matchesAll &= t.TransactionAmount.Currency == "EUR"; + return matchesAll; + })); + Assert.That(transactions.PendingTransactions, Has.Count.GreaterThanOrEqualTo(1)); + }); + } + + /// + /// Tests the retrieval of transactions within a specific time frame. + /// + [Test] + public async Task GetTransactionRange() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.AccountsEndpointMockData.GetTransactionRange, + HttpStatusCode.OK); + +#if NET6_0_OR_GREATER + var startDate = new DateOnly(2022, 08, 04); + var balancesResponse = + await apiClient.AccountsEndpoint.GetTransactions(A.Dummy(), startDate, + DateOnly.FromDateTime(DateTime.Now.Subtract(TimeSpan.FromHours(24)))); +#else + var startDate = new DateTime(2022, 08, 04); + var balancesResponse = await apiClient.AccountsEndpoint.GetTransactions(A.Dummy(), startDate, + DateTime.Now.Subtract(TimeSpan.FromDays(1))); +#endif + + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(balancesResponse, HttpStatusCode.OK); + Assert.That(balancesResponse.Result!.BookedTransactions, Has.Count.EqualTo(2)); + } + + #endregion + + #region RequestsWithErrors + + /// + /// Tests the retrieval of an account that does not exist. This should return an error. + /// + [Test] + public async Task GetAccountWithInvalidGuid() + { + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.AccountsEndpointMockData.GetAccountWithInvalidGuid, HttpStatusCode.BadRequest); + + var accountResponse = await apiClient.AccountsEndpoint.GetAccount(InvalidGuid); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(accountResponse, HttpStatusCode.BadRequest); + AssertionHelpers.AssertBasicResponseMatchesExpectations(accountResponse.Error, "Invalid Account ID", + $"{InvalidGuid} is not a valid Account UUID. "); + }); + } + + /// + /// Tests the retrieval of an account that does not exist. This should return an error. + /// + [Test] + public async Task GetAccountThatDoesNotExist() + { + var apiClient = + TestHelpers.GetMockClient(TestHelpers.MockData.AccountsEndpointMockData.GetAccountThatDoesNotExist, + HttpStatusCode.NotFound); + + var accountResponse = await apiClient.AccountsEndpoint.GetAccount(A.Dummy()); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(accountResponse, HttpStatusCode.NotFound); + AssertionHelpers.AssertBasicResponseMatchesExpectations(accountResponse.Error, "Not found.", "Not found."); + }); + } + + /// + /// Tests the retrieval of balances of an account that does not exist. This should return an error. + /// + [Test] + public async Task GetBalancesForAccountThatDoesNotExist() + { + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.AccountsEndpointMockData.GetBalancesForAccountThatDoesNotExist, + HttpStatusCode.NotFound); + + var nonExistingAccountId = Guid.Parse("f1d53c46-260d-4556-82df-4e5fed58e37c"); + var balancesResponse = await apiClient.AccountsEndpoint.GetBalances(A.Dummy()); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(balancesResponse, HttpStatusCode.NotFound); + AssertionHelpers.AssertBasicResponseMatchesExpectations(balancesResponse.Error, + $"Account ID {nonExistingAccountId} not found", + "Please check whether you specified a valid Account ID"); + }); + } + + /// + /// Tests the retrieval of transactions within a specific time frame in the future. This should return an error. + /// + [Test] + public async Task GetTransactionRangeInFuture() + { + var startDate = new DateTime(year: 2024, month: 4, day: 21); + var endDate = new DateTime(year: 2024, month: 5, day: 21); + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.AccountsEndpointMockData.GetTransactionRangeInFuture, HttpStatusCode.BadRequest); + + // Returns AccountsError +#if NET6_0_OR_GREATER + var transactionsResponse = await apiClient.AccountsEndpoint.GetTransactions(A.Dummy(), + DateOnly.FromDateTime(startDate), DateOnly.FromDateTime(endDate)); +#else + var transactionsResponse = await apiClient.AccountsEndpoint.GetTransactions(A.Dummy(), + startDate, endDate); +#endif + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(transactionsResponse, HttpStatusCode.BadRequest); + Assert.That(transactionsResponse.Error?.StartDateError, Is.Not.Null); + Assert.That(transactionsResponse.Error?.EndDateError, Is.Not.Null); + AssertionHelpers.AssertBasicResponseMatchesExpectations(transactionsResponse.Error?.StartDateError, + "Date can't be in future", + "'2024-04-21' can't be greater than 2024-04-20. Specify correct date range"); + AssertionHelpers.AssertBasicResponseMatchesExpectations(transactionsResponse.Error?.EndDateError, + "Date can't be in future", + "'2024-05-21' can't be greater than 2024-04-20. Specify correct date range"); + }); + } + + /// + /// Tests the retrieval of transactions within a specific time frame where the date range is incorrect, since the endDate is before the startDate. This should throw an exception. + /// + [Test] + public void GetTransactionRangeWithIncorrectRange() + { + var apiClient = TestHelpers.GetMockClient(null!, HttpStatusCode.BadRequest); + var startDate = DateTime.Now.AddMonths(-1); + var endDateBeforeStartDate = startDate.AddDays(-1); + +#if NET6_0_OR_GREATER + var exception = Assert.ThrowsAsync(async () => + await apiClient.AccountsEndpoint.GetTransactions(A.Dummy(), DateOnly.FromDateTime(startDate), + DateOnly.FromDateTime(endDateBeforeStartDate))); + + Assert.That(exception.Message, + Is.EqualTo( + $"Starting date '{DateOnly.FromDateTime(startDate)}' is greater than end date '{DateOnly.FromDateTime(endDateBeforeStartDate)}'. When specifying date range, starting date must precede the end date.")); +#else + var exception = Assert.ThrowsAsync(async () => + await apiClient.AccountsEndpoint.GetTransactions(A.Dummy(), startDate, endDateBeforeStartDate)); + + Assert.That(exception.Message, + Is.EqualTo( + $"Starting date '{startDate}' is greater than end date '{endDateBeforeStartDate}'. When specifying date range, starting date must precede the end date.")); +#endif + } + + #endregion +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/AgreementsEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/AgreementsEndpointTests.cs new file mode 100644 index 0000000..7da91a8 --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/AgreementsEndpointTests.cs @@ -0,0 +1,249 @@ +using FakeItEasy; +using RobinTTY.NordigenApiClient.Models.Requests; +using RobinTTY.NordigenApiClient.Tests.Shared; + +namespace RobinTTY.NordigenApiClient.Tests.Mocks.Endpoints; + +public class AgreementsEndpointTests +{ + #region RequestsWithSuccessfulResponse + + /// + /// Tests the retrieval of end user agreements. + /// + [Test] + public async Task GetAgreements() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.AgreementsEndpointMockData.GetAgreements, + HttpStatusCode.OK); + + var agreements = await apiClient.AgreementsEndpoint.GetAgreements(100, 0); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(agreements, HttpStatusCode.OK); + + var responseAgreement = agreements.Result!.Results.First(); + Assert.Multiple(() => + { + Assert.That(agreements.Result!.Count, Is.EqualTo(1)); + Assert.That(agreements.Result!.Next, + Is.EqualTo(new Uri( + "https://bankaccountdata.gocardless.com/api/v2/agreements/enduser/?limit=100&offset=0"))); + Assert.That(agreements.Result!.Previous, + Is.EqualTo(new Uri( + "https://bankaccountdata.gocardless.com/api/v2/agreements/enduser/?limit=100&offset=0"))); + Assert.That(agreements.Result!.Results.Count(), Is.EqualTo(1)); + + Assert.That(responseAgreement.Id, Is.EqualTo(new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6"))); + Assert.That(responseAgreement.Created.ToUniversalTime(), + Is.EqualTo(DateTime.Parse("2024-04-08T20:57:00.550Z").ToUniversalTime())); + Assert.That(((DateTime) responseAgreement.Accepted!).ToUniversalTime(), + Is.EqualTo(DateTime.Parse("2024-04-08T20:57:00.550Z").ToUniversalTime())); + Assert.That(responseAgreement.InstitutionId, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); + Assert.That(responseAgreement.MaxHistoricalDays, Is.EqualTo(90)); + Assert.That(responseAgreement.AccessValidForDays, Is.EqualTo(90)); + Assert.That(responseAgreement.AccessScope, Has.Count.EqualTo(3)); + + var expectedAccessScopes = new[] {"balances", "details", "transactions"}; + Assert.That(responseAgreement.AccessScope, Is.EqualTo(expectedAccessScopes)); + }); + } + + /// + /// Tests the retrieval of an end user agreement by id. + /// + [Test] + public async Task GetAgreement() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.AgreementsEndpointMockData.GetAgreement, + HttpStatusCode.OK); + + var agreement = await apiClient.AgreementsEndpoint.GetAgreement(A.Dummy()); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(agreement, HttpStatusCode.OK); + + Assert.Multiple(() => + { + Assert.That(agreement.Result!.Id, Is.EqualTo(new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6"))); + Assert.That(agreement.Result!.InstitutionId, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); + Assert.That(agreement.Result!.MaxHistoricalDays, Is.EqualTo(90)); + Assert.That(agreement.Result!.AccessValidForDays, Is.EqualTo(90)); + Assert.That(agreement.Result!.Created.ToUniversalTime(), + Is.EqualTo(DateTime.Parse("2024-04-08T22:54:54.869Z").ToUniversalTime())); + Assert.That(((DateTime) agreement.Result!.Accepted!).ToUniversalTime(), + Is.EqualTo(DateTime.Parse("2024-04-08T22:54:54.869Z").ToUniversalTime())); + var expectedAccessScopes = new[] {"balances", "details", "transactions"}; + Assert.That(agreement.Result!.AccessScope, Is.EqualTo(expectedAccessScopes)); + }); + } + + /// + /// Tests the creation of end user agreements. + /// + [Test] + public async Task CreateAgreement() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.AgreementsEndpointMockData.CreateAgreement, + HttpStatusCode.Created); + + var agreementRequest = new CreateAgreementRequest(145, 145, + ["balances", "details", "transactions"], "SANDBOXFINANCE_SFIN0000"); + var createResponse = await apiClient.AgreementsEndpoint.CreateAgreement(agreementRequest); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(createResponse, HttpStatusCode.Created); + + Assert.Multiple(() => + { + Assert.That(createResponse.Result!.Id, Is.EqualTo(new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa7"))); + Assert.That(createResponse.Result!.InstitutionId, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); + Assert.That(createResponse.Result!.MaxHistoricalDays, Is.EqualTo(145)); + Assert.That(createResponse.Result!.AccessValidForDays, Is.EqualTo(145)); + + var expectedAccessScopes = new[] {"balances", "details", "transactions"}; + Assert.That(createResponse.Result!.AccessScope, Is.EqualTo(expectedAccessScopes)); + }); + } + + /// + /// Tests the creation of end user agreements. + /// + [Test] + public async Task DeleteAgreement() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.AgreementsEndpointMockData.DeleteAgreement, + HttpStatusCode.OK); + + var result = await apiClient.AgreementsEndpoint.DeleteAgreement(A.Dummy()); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(result, HttpStatusCode.OK); + Assert.Multiple(() => + { + Assert.That(result.Result?.Summary, Is.EqualTo("End User Agreement deleted")); + Assert.That(result.Result?.Detail, + Is.EqualTo("End User Agreement bb37bc52-5b1d-44f9-b1cd-ec9594f25387 deleted")); + }); + } + + #endregion + + #region RequestsWithErrors + + /// + /// Tests the retrieval of an agreement with an invalid guid. + /// + [Test] + public async Task GetAgreementWithInvalidGuid() + { + const string guid = "f84d7b8-dee4-4cd9-bc6d-842ef78f6028"; + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.AgreementsEndpointMockData.GetAgreementWithInvalidGuid, + HttpStatusCode.BadRequest); + + var response = await apiClient.AgreementsEndpoint.GetAgreement(guid); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + AssertionHelpers.AssertBasicResponseMatchesExpectations(response.Error, "Invalid EndUserAgreement ID", + $"{guid} is not a valid EndUserAgreement UUID. "); + }); + } + + /// + /// Tests the creation of an end user agreement with an invalid institution id. + /// + [Test] + public async Task CreateAgreementWithInvalidInstitutionId() + { + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.AgreementsEndpointMockData.CreateAgreementWithInvalidInstitutionId, + HttpStatusCode.BadRequest); + var agreement = new CreateAgreementRequest(90, 90, + ["balances", "details", "transactions"], "invalid_institution"); + + var response = await apiClient.AgreementsEndpoint.CreateAgreement(agreement); + + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + Assert.Multiple(() => + { + Assert.That(response.Error!.InstitutionIdError, Is.Not.Null); + Assert.That(response.Error!.InstitutionIdError!.Summary, + Is.EqualTo("Unknown Institution ID invalid_institution")); + Assert.That(response.Error!.InstitutionIdError!.Detail, + Is.EqualTo("Get Institution IDs from /institutions/?country={$COUNTRY_CODE}")); + }); + } + + /// + /// Tests the creation of an end user agreement with an empty institution id and empty access scopes. + /// + [Test] + public async Task CreateAgreementWithEmptyInstitutionIdAndAccessScopes() + { + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.AgreementsEndpointMockData.CreateAgreementWithEmptyInstitutionIdAndAccessScopes, + HttpStatusCode.BadRequest); + var agreement = new CreateAgreementRequest(90, 90, null!, null!); + + var response = await apiClient.AgreementsEndpoint.CreateAgreement(agreement); + + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + Assert.Multiple(() => + { + Assert.That(response.Error!.InstitutionIdError, Is.Not.Null); + Assert.That(response.Error!.InstitutionIdError!.Summary, Is.EqualTo("This field may not be null.")); + Assert.That(response.Error!.InstitutionIdError!.Detail, Is.EqualTo("This field may not be null.")); + + Assert.That(response.Error!.AccessScopeError!.Summary, Is.EqualTo("This field may not be null.")); + Assert.That(response.Error!.AccessScopeError!.Detail, Is.EqualTo("This field may not be null.")); + + }); + } + + /// + /// Tests the creation of an end user agreement with invalid parameters. + /// + [Test] + public async Task CreateAgreementWithInvalidParams() + { + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.AgreementsEndpointMockData.CreateAgreementWithInvalidParams, + HttpStatusCode.BadRequest); + var agreement = new CreateAgreementRequest(200, 200, + ["balances", "details", "transactions", "invalid", "invalid2"], "SANDBOXFINANCE_SFIN0000"); + + var response = await apiClient.AgreementsEndpoint.CreateAgreement(agreement); + var result = response.Error!; + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + Assert.That(new[] {result.InstitutionIdError, result.AgreementError}, Has.All.Null); + Assert.That(result.AccessScopeError!.Detail, + Is.EqualTo("Choose one or several from ['balances', 'details', 'transactions']")); + Assert.That(result.AccessValidForDaysError!.Detail, + Is.EqualTo("access_valid_for_days must be > 0 and <= 180")); + Assert.That(result.MaxHistoricalDaysError!.Detail, + Is.EqualTo( + "max_historical_days must be > 0 and <= SANDBOXFINANCE_SFIN0000 transaction_total_days (90)")); + }); + } + + [Test] + public async Task CreateAgreementWithInvalidParamsAtPolishInstitution() + { + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.AgreementsEndpointMockData.CreateAgreementWithInvalidParamsAtPolishInstitution, + HttpStatusCode.BadRequest); + var agreement = new CreateAgreementRequest(90, 90, + ["balances", "transactions"], "PKO_BPKOPLPW"); + + var response = await apiClient.AgreementsEndpoint.CreateAgreement(agreement); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + Assert.That(new[] {response.Error!.InstitutionIdError, response.Error!.AgreementError}, Has.All.Null); + Assert.That(response.Error!.Detail, + Is.EqualTo("For this institution the following scopes are required together: ['details', 'balances']")); + Assert.That(response.Error!.Summary, Is.EqualTo("Institution access scope dependencies error")); + }); + } + + #endregion +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/InstitutionsEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/InstitutionsEndpointTests.cs new file mode 100644 index 0000000..fe45c75 --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/InstitutionsEndpointTests.cs @@ -0,0 +1,110 @@ +using RobinTTY.NordigenApiClient.Models.Responses; +using RobinTTY.NordigenApiClient.Tests.Shared; + +namespace RobinTTY.NordigenApiClient.Tests.Mocks.Endpoints; + +public class InstitutionsEndpointTests +{ + #region RequestsWithSuccessfulResponse + + /// + /// Tests the retrieving of institutions for all countries and a specific country (Great Britain). + /// + [Test] + public async Task GetInstitutions() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.InstitutionsEndpointMockData.GetInstitutions, + HttpStatusCode.OK); + + var institutions = await apiClient.InstitutionsEndpoint.GetInstitutions(); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(institutions, HttpStatusCode.OK); + Assert.That(institutions.Result!, Has.Count.EqualTo(2)); + }); + } + + /// + /// Tests the retrieving of a specific institution. + /// + [Test] + public async Task GetInstitution() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.InstitutionsEndpointMockData.GetInstitution, + HttpStatusCode.OK); + + var institution = await apiClient.InstitutionsEndpoint.GetInstitution("N26_NTSBDEB1"); + + var expectedSupportedFeatures = new[] + { + "account_selection", + "business_accounts", + "card_accounts", + "payments", + "private_accounts" + }; + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(institution, HttpStatusCode.OK); + Assert.That(institution.Result!.Bic, Is.EqualTo("NTSBDEB1")); + Assert.That(institution.Result!.Id, Is.EqualTo("N26_NTSBDEB1")); + Assert.That(institution.Result!.Name, Is.EqualTo("N26 Bank")); + Assert.That(institution.Result!.TransactionTotalDays, Is.EqualTo(90)); + + Assert.That(institution.Result!.SupportedPayments?.SinglePayment, + Contains.Item(PaymentProduct.SepaCreditTransfers)); + Assert.That(institution.Result!.SupportedPayments?.SinglePayment, + Contains.Item(PaymentProduct.InstantSepaCreditTransfer)); + Assert.That(institution.Result!.SupportedFeatures, Is.EqualTo(expectedSupportedFeatures)); + Assert.That(institution.Result!.IdentificationCodes, Is.Empty); + }); + } + + #endregion + + #region RequestsWithErrors + + /// + /// Tests the retrieving of institutions for a country which is not covered by the API. + /// + [Test] + public async Task GetInstitutionsForNotCoveredCountry() + { + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.InstitutionsEndpointMockData.GetInstitutionsForNotCoveredCountry, + HttpStatusCode.BadRequest); + + var response = await apiClient.InstitutionsEndpoint.GetInstitutions("US"); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + Assert.That(response.Error!.Detail, Is.EqualTo("US is not a valid choice.")); + Assert.That(response.Error!.Summary, Is.EqualTo("Invalid country choice.")); + }); + } + + /// + /// Tests the retrieving of an institution with an invalid id. + /// + [Test] + public async Task GetNonExistingInstitution() + { + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.InstitutionsEndpointMockData.GetNonExistingInstitution, + HttpStatusCode.NotFound); + + var response = await apiClient.InstitutionsEndpoint.GetInstitution("invalid_id"); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.NotFound); + Assert.That(response.Error!.Detail, Is.EqualTo("Not found.")); + Assert.That(response.Error!.Summary, Is.EqualTo("Not found.")); + }); + } + + #endregion +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/RequisitionsEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/RequisitionsEndpointTests.cs new file mode 100644 index 0000000..1aecb45 --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/RequisitionsEndpointTests.cs @@ -0,0 +1,237 @@ +using FakeItEasy; +using RobinTTY.NordigenApiClient.Models.Requests; +using RobinTTY.NordigenApiClient.Models.Responses; +using RobinTTY.NordigenApiClient.Tests.Shared; + +namespace RobinTTY.NordigenApiClient.Tests.Mocks.Endpoints; + +public class RequisitionsEndpointTests +{ + #region RequestsWithSuccessfulResponse + + /// + /// Tests the retrieving of all existing requisitions. + /// + [Test] + public async Task GetRequisitions() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.RequisitionsEndpointMockData.GetRequisitions, + HttpStatusCode.OK); + + var requisitions = await apiClient.RequisitionsEndpoint.GetRequisitions(100, 0); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(requisitions, HttpStatusCode.OK); + + var result = requisitions.Result!; + var requisition = result.Results.First(); + var expectedAccountGuids = new List + {new("3fa85f64-5717-4562-b3fc-2c963f66afa6"), new("3fa85f64-5717-4562-b3fc-2c963f66afa7")}; + + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.Next, + Is.EqualTo(new Uri("https://bankaccountdata.gocardless.com/api/v2/requisitions/?limit=100&offset=0"))); + Assert.That(result.Previous, + Is.EqualTo(new Uri("https://bankaccountdata.gocardless.com/api/v2/requisitions/?limit=100&offset=0"))); + Assert.That(requisition.Id, Is.EqualTo(new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6"))); + + Assert.That(requisition.Created.ToUniversalTime(), + Is.EqualTo(DateTime.Parse("2024-04-12T23:50:34.962Z").ToUniversalTime())); + Assert.That(requisition.Redirect, Is.EqualTo(new Uri("https://www.robintty.com"))); + Assert.That(requisition.Status, Is.EqualTo(RequisitionStatus.Created)); + Assert.That(requisition.InstitutionId, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); + + Assert.That(requisition.AgreementId, Is.EqualTo(new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6"))); + Assert.That(requisition.Reference, Is.EqualTo("example-reference")); + Assert.That(requisition.Accounts, Is.EqualTo(expectedAccountGuids)); + Assert.That(requisition.UserLanguage, Is.EqualTo("EN")); + + Assert.That(requisition.AuthenticationLink, + Is.EqualTo(new Uri( + "https://ob.nordigen.com/psd2/start/3fa85f64-5717-4562-b3fc-2c963f66afa6/SANDBOXFINANCE_SFIN0000"))); + Assert.That(requisition.SocialSecurityNumber, Is.EqualTo("555-50-1234")); + Assert.That(requisition.AccountSelection, Is.False); + Assert.That(requisition.RedirectImmediate, Is.True); + }); + } + + /// + /// Tests the retrieving of a specific requisition. + /// + [Test] + public async Task GetRequisition() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.RequisitionsEndpointMockData.GetRequisition, + HttpStatusCode.OK); + + var requisition = await apiClient.RequisitionsEndpoint.GetRequisition(A.Dummy()); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(requisition, HttpStatusCode.OK); + var expectedAccountGuids = new List + {new("3fa85f64-5717-4562-b3fc-2c963f66afa6"), new("3fa85f64-5717-4562-b3fc-2c963f66afa7")}; + + Assert.Multiple(() => + { + Assert.That(requisition.Result!.Id, Is.EqualTo(new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6"))); + Assert.That(requisition.Result!.Created.ToUniversalTime(), + Is.EqualTo(DateTime.Parse("2024-04-12T23:50:34.962Z").ToUniversalTime())); + Assert.That(requisition.Result!.Redirect, Is.EqualTo(new Uri("https://www.robintty.com"))); + Assert.That(requisition.Result!.Status, Is.EqualTo(RequisitionStatus.Created)); + Assert.That(requisition.Result!.InstitutionId, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); + + Assert.That(requisition.Result!.AgreementId, Is.EqualTo(new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6"))); + Assert.That(requisition.Result!.Reference, Is.EqualTo("example-reference")); + Assert.That(requisition.Result!.Accounts, Is.EqualTo(expectedAccountGuids)); + Assert.That(requisition.Result!.UserLanguage, Is.EqualTo("EN")); + + Assert.That(requisition.Result!.AuthenticationLink, + Is.EqualTo(new Uri( + "https://ob.nordigen.com/psd2/start/3fa85f64-5717-4562-b3fc-2c963f66afa6/SANDBOXFINANCE_SFIN0000"))); + Assert.That(requisition.Result!.SocialSecurityNumber, Is.EqualTo("555-50-1234")); + Assert.That(requisition.Result!.AccountSelection, Is.False); + Assert.That(requisition.Result!.RedirectImmediate, Is.True); + }); + } + + /// + /// Tests the creation of a new requisition. + /// + [Test] + public async Task CreateRequisitions() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.RequisitionsEndpointMockData.CreateRequisition, + HttpStatusCode.Created); + + var requisition = await apiClient.RequisitionsEndpoint.CreateRequisition(A.Fake()); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(requisition, HttpStatusCode.Created); + + Assert.Multiple(() => + { + Assert.That(requisition.Result!.Redirect, Is.EqualTo(new Uri("https://www.robintty.com"))); + Assert.That(requisition.Result!.InstitutionId, Is.EqualTo("SANDBOXFINANCE_SFIN0000")); + Assert.That(requisition.Result!.AgreementId, Is.EqualTo(new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6"))); + Assert.That(requisition.Result!.Reference, Is.EqualTo("example-reference")); + + Assert.That(requisition.Result!.UserLanguage, Is.EqualTo("EN")); + Assert.That(requisition.Result!.SocialSecurityNumber, Is.EqualTo("555-50-1234")); + Assert.That(requisition.Result!.AccountSelection, Is.False); + Assert.That(requisition.Result!.RedirectImmediate, Is.True); + }); + } + + /// + /// Tests the deletion of a requisition. + /// + [Test] + public async Task DeleteRequisitions() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.RequisitionsEndpointMockData.DeleteRequisition, + HttpStatusCode.OK); + + var result = await apiClient.RequisitionsEndpoint.DeleteRequisition(A.Dummy()); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(result, HttpStatusCode.OK); + + Assert.Multiple(() => + { + Assert.That(result.Result?.Summary, Is.EqualTo("Requisition deleted")); + Assert.That(result.Result?.Detail, + Is.EqualTo( + "Requisition b5462cad-5a7f-42e1-881d-d0fa066f54bc deleted with all its End User Agreements")); + }); + } + + #endregion + + #region RequestsWithErrors + + /// + /// Tests the retrieval of a requisition with an invalid guid. + /// + [Test] + public async Task GetRequisitionWithInvalidGuid() + { + const string guid = "f84d7b8-dee4-4cd9-bc6d-842ef78f6028"; + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.RequisitionsEndpointMockData.GetRequisitionWithInvalidGuid, HttpStatusCode.NotFound); + + var response = await apiClient.RequisitionsEndpoint.GetRequisition(guid); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.NotFound); + Assert.That(response.Error!.Detail, Is.EqualTo("Not found.")); + }); + } + + /// + /// Tests the creation of an end user agreement with invalid id. + /// + [Test] + public async Task CreateRequisitionWithInvalidId() + { + var redirect = new Uri("ftp://ftp.test.com"); + var agreementId = Guid.Empty; + var requisitionRequest = + new CreateRequisitionRequest(redirect, "123", "internal_reference", "EN", agreementId, null, true, true); + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.RequisitionsEndpointMockData.CreateRequisitionWithInvalidId, HttpStatusCode.BadRequest); + + var response = await apiClient.RequisitionsEndpoint.CreateRequisition(requisitionRequest); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + Assert.That(response.Error!.Summary, Is.EqualTo("Invalid ID")); + Assert.That(response.Error!.Detail, + Is.EqualTo("00000000-0000-0000-0000-000000000000 is not a valid UUID. ")); + }); + } + + /// + /// Tests the creation of an end user agreement with invalid parameters in the . + /// + [Test] + public async Task CreateRequisitionWithInvalidParameters() + { + var redirect = new Uri("ftp://ftp.test.com"); + // Agreement belongs to SANDBOXFINANCE_SFIN0000 + var agreementId = Guid.Parse("f34c3c71-4a62-4a25-b998-3f37ddce84a2"); + var requisitionRequest = + new CreateRequisitionRequest(redirect, "", "", "AB", agreementId, "12345", true, true); + var apiClient = TestHelpers.GetMockClient( + TestHelpers.MockData.RequisitionsEndpointMockData.CreateRequisitionWithInvalidParameters, HttpStatusCode.BadRequest); + + var response = await apiClient.RequisitionsEndpoint.CreateRequisition(requisitionRequest); + + Assert.Multiple(() => + { + AssertionHelpers.AssertNordigenApiResponseIsUnsuccessful(response, HttpStatusCode.BadRequest); + + Assert.That(response.Error!.AccountSelectionError!.Summary, Is.EqualTo("Account selection not supported")); + Assert.That(response.Error!.AccountSelectionError!.Detail, + Is.EqualTo("Account selection not supported for ")); + + Assert.That(response.Error!.AgreementError!.Summary, Is.EqualTo("Incorrect Institution ID ")); + Assert.That(response.Error!.AgreementError!.Detail, + Is.EqualTo( + "Provided Institution ID: '' for requisition does not match EUA institution ID 'SANDBOXFINANCE_SFIN0000'. Please provide correct institution ID: 'SANDBOXFINANCE_SFIN0000'")); + + Assert.That(response.Error!.InstitutionIdError!.Summary, Is.EqualTo("This field may not be blank.")); + Assert.That(response.Error!.InstitutionIdError!.Detail, Is.EqualTo("This field may not be blank.")); + + Assert.That(response.Error!.InstitutionIdError!.Summary, Is.EqualTo("This field may not be blank.")); + Assert.That(response.Error!.InstitutionIdError!.Detail, Is.EqualTo("This field may not be blank.")); + + Assert.That(response.Error!.SocialSecurityNumberError!.Summary, + Is.EqualTo("SSN verification not supported")); + Assert.That(response.Error!.SocialSecurityNumberError!.Detail, + Is.EqualTo("SSN verification not supported for ")); + + Assert.That(response.Error!.UserLanguageError!.Summary, + Is.EqualTo("Provided user_language is invalid or not supported")); + Assert.That(response.Error!.UserLanguageError!.Detail, + Is.EqualTo("'AB' is an invalid or unsupported language")); + }); + } + + #endregion +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/TokenEndpointTests.cs b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/TokenEndpointTests.cs new file mode 100644 index 0000000..16e0c5f --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Endpoints/TokenEndpointTests.cs @@ -0,0 +1,69 @@ +using FakeItEasy; +using Microsoft.IdentityModel.JsonWebTokens; +using RobinTTY.NordigenApiClient.Tests.Shared; +using RobinTTY.NordigenApiClient.Utility; + +namespace RobinTTY.NordigenApiClient.Tests.Mocks.Endpoints; + +public class TokenEndpointTests +{ + #region RequestsWithSuccessfulResponse + + /// + /// Tests the retrieving of a new token. + /// + [Test] + public async Task GetNewToken() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.TokenEndpointMockData.GetNewToken, + HttpStatusCode.OK, addDefaultAuthToken: false); + + var tokenPair = await apiClient.TokenEndpoint.GetTokenPair(); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(tokenPair, HttpStatusCode.OK); + + Assert.Multiple(() => + { + Assert.That(tokenPair.Result!.AccessExpires, Is.EqualTo(86_400)); + Assert.That(tokenPair.Result!.AccessToken.IsExpired(), Is.False); + Assert.That(tokenPair.Result!.AccessToken.EncodedToken, + Is.EqualTo( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjozMzI3MDExNzU5NH0.gEa5VdPSqZW2xk9IqCEqiw6bzBOer_uAR1yp2XK7FFo")); + + Assert.That(tokenPair.Result!.RefreshExpires, Is.EqualTo(2_592_000)); + Assert.That(tokenPair.Result!.RefreshToken.IsExpired(), Is.False); + Assert.That(tokenPair.Result!.RefreshToken.EncodedToken, + Is.EqualTo( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MzMyNzAxMTc1OTR9.xfOrczY3KvG-SiHLZkVLPas017ZX8DHkcCN78Xd9cac")); + }); + } + + /// + /// Tests the retrieving of a new token. + /// + [Test] + public async Task RefreshAccessToken() + { + var apiClient = TestHelpers.GetMockClient(TestHelpers.MockData.TokenEndpointMockData.RefreshAccessToken, + HttpStatusCode.OK, addDefaultAuthToken: false); + + var tokenPair = await apiClient.TokenEndpoint.RefreshAccessToken(A.Fake(options => + { + options.WithArgumentsForConstructor(new object[] + { + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MzMyNzAxMTc1OTR9.xfOrczY3KvG-SiHLZkVLPas017ZX8DHkcCN78Xd9cac" + }); + })); + AssertionHelpers.AssertNordigenApiResponseIsSuccessful(tokenPair, HttpStatusCode.OK); + + Assert.Multiple(() => + { + Assert.That(tokenPair.Result!.AccessExpires, Is.EqualTo(86_400)); + Assert.That(tokenPair.Result!.AccessToken.IsExpired(), Is.False); + Assert.That(tokenPair.Result!.AccessToken.EncodedToken, + Is.EqualTo( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjozMzI3MDExNzU5NH0.gEa5VdPSqZW2xk9IqCEqiw6bzBOer_uAR1yp2XK7FFo")); + }); + } + + #endregion +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Mocks/FakeHttpMessageHandler.cs b/src/RobinTTY.NordigenApiClient.Tests/Mocks/FakeHttpMessageHandler.cs new file mode 100644 index 0000000..b184a01 --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Mocks/FakeHttpMessageHandler.cs @@ -0,0 +1,12 @@ +namespace RobinTTY.NordigenApiClient.Tests.Mocks; + +public abstract class FakeHttpMessageHandler : HttpMessageHandler +{ + public abstract Task FakeSendAsync( + HttpRequestMessage request, CancellationToken cancellationToken); + + // sealed so FakeItEasy won't intercept calls to this method + protected sealed override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => FakeSendAsync(request, cancellationToken); +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Mocks/Responses/MockResponsesModel.cs b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Responses/MockResponsesModel.cs new file mode 100644 index 0000000..8ec5a06 --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Responses/MockResponsesModel.cs @@ -0,0 +1,122 @@ +using RobinTTY.NordigenApiClient.Models.Errors; +using RobinTTY.NordigenApiClient.Models.Jwt; +using RobinTTY.NordigenApiClient.Models.Responses; + +namespace RobinTTY.NordigenApiClient.Tests.Mocks.Responses; + +internal class MockResponsesModel( + AccountsEndpointMockData accountsEndpointMockData, + AgreementsEndpointMockData agreementsEndpointMockData, + InstitutionsEndpointMockData institutionsEndpointMockData, + RequisitionsEndpointMockData requisitionsEndpointMockData, + TokenEndpointMockData tokenEndpointMockData, + CredentialMockData credentialMockData) +{ + public AccountsEndpointMockData AccountsEndpointMockData { get; set; } = accountsEndpointMockData; + public AgreementsEndpointMockData AgreementsEndpointMockData { get; set; } = agreementsEndpointMockData; + public InstitutionsEndpointMockData InstitutionsEndpointMockData { get; set; } = institutionsEndpointMockData; + public RequisitionsEndpointMockData RequisitionsEndpointMockData { get; set; } = requisitionsEndpointMockData; + public TokenEndpointMockData TokenEndpointMockData { get; set; } = tokenEndpointMockData; + public CredentialMockData CredentialMockData { get; set; } = credentialMockData; +} + +internal class AccountsEndpointMockData( + BankAccount getAccount, + BalanceJsonWrapper getBalances, + BankAccountDetailsWrapper getAccountDetails, + AccountTransactionsWrapper getTransactions, + AccountTransactionsWrapper getTransactionRange, + AccountsError getTransactionRangeInFuture, + AccountsError getAccountWithInvalidGuid, + AccountsError getAccountThatDoesNotExist, + AccountsError getBalancesForAccountThatDoesNotExist) +{ + public BankAccount GetAccount { get; set; } = getAccount; + public BalanceJsonWrapper GetBalances { get; set; } = getBalances; + public BankAccountDetailsWrapper GetAccountDetails { get; set; } = getAccountDetails; + public AccountTransactionsWrapper GetTransactions { get; set; } = getTransactions; + public AccountTransactionsWrapper GetTransactionRange { get; set; } = getTransactionRange; + public AccountsError GetTransactionRangeInFuture { get; set; } = getTransactionRangeInFuture; + public AccountsError GetAccountWithInvalidGuid { get; set; } = getAccountWithInvalidGuid; + public AccountsError GetAccountThatDoesNotExist { get; set; } = getAccountThatDoesNotExist; + public AccountsError GetBalancesForAccountThatDoesNotExist { get; set; } = getBalancesForAccountThatDoesNotExist; +} + +internal class AgreementsEndpointMockData( + ResponsePage getAgreements, + Agreement createAgreement, + Agreement getAgreement, + BasicResponse deleteAgreement, + BasicResponse getAgreementWithInvalidGuid, + CreateAgreementError createAgreementWithInvalidInstitutionId, + CreateAgreementError createAgreementWithInvalidParams, + CreateAgreementError createAgreementWithEmptyInstitutionIdAndAccessScopes, + CreateAgreementError createAgreementWithInvalidParamsAtPolishInstitution) +{ + public ResponsePage GetAgreements { get; set; } = getAgreements; + public Agreement CreateAgreement { get; set; } = createAgreement; + public Agreement GetAgreement { get; set; } = getAgreement; + public BasicResponse DeleteAgreement { get; set; } = deleteAgreement; + + public BasicResponse GetAgreementWithInvalidGuid { get; set; } = getAgreementWithInvalidGuid; + + public CreateAgreementError CreateAgreementWithInvalidInstitutionId { get; set; } = + createAgreementWithInvalidInstitutionId; + + public CreateAgreementError CreateAgreementWithInvalidParams { get; set; } = createAgreementWithInvalidParams; + + public CreateAgreementError CreateAgreementWithEmptyInstitutionIdAndAccessScopes { get; set; } = + createAgreementWithEmptyInstitutionIdAndAccessScopes; + + public CreateAgreementError CreateAgreementWithInvalidParamsAtPolishInstitution { get; set; } = + createAgreementWithInvalidParamsAtPolishInstitution; +} + +internal class InstitutionsEndpointMockData( + List getInstitutions, + Institution getInstitution, + InstitutionsErrorInternal getInstitutionsForNotCoveredCountry, + BasicResponse getNonExistingInstitution) +{ + public List GetInstitutions { get; set; } = getInstitutions; + public Institution GetInstitution { get; set; } = getInstitution; + + public InstitutionsErrorInternal GetInstitutionsForNotCoveredCountry { get; set; } = + getInstitutionsForNotCoveredCountry; + + public BasicResponse GetNonExistingInstitution { get; set; } = getNonExistingInstitution; +} + +internal class RequisitionsEndpointMockData( + ResponsePage getRequisitions, + Requisition getRequisition, + Requisition createRequisition, + BasicResponse deleteRequisition, + BasicResponse getRequisitionWithInvalidGuid, + CreateRequisitionError createRequisitionWithInvalidId, + CreateRequisitionError createRequisitionWithInvalidParameters) +{ + public ResponsePage GetRequisitions { get; set; } = getRequisitions; + public Requisition GetRequisition { get; set; } = getRequisition; + public Requisition CreateRequisition { get; set; } = createRequisition; + public BasicResponse DeleteRequisition { get; set; } = deleteRequisition; + public BasicResponse GetRequisitionWithInvalidGuid { get; set; } = getRequisitionWithInvalidGuid; + public CreateRequisitionError CreateRequisitionWithInvalidId { get; set; } = createRequisitionWithInvalidId; + + public CreateRequisitionError CreateRequisitionWithInvalidParameters { get; set; } = + createRequisitionWithInvalidParameters; +} + +internal class TokenEndpointMockData( + JsonWebTokenPair getNewToken, + JsonWebAccessToken refreshAccessToken) +{ + public JsonWebTokenPair GetNewToken { get; set; } = getNewToken; + public JsonWebAccessToken RefreshAccessToken { get; set; } = refreshAccessToken; +} + +internal class CredentialMockData(BasicResponse noAccountForGivenCredentialsError, BasicResponse ipNotWhitelistedError) +{ + public BasicResponse NoAccountForGivenCredentialsError { get; set; } = noAccountForGivenCredentialsError; + public BasicResponse IpNotWhitelistedError { get; set; } = ipNotWhitelistedError; +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Mocks/Responses/responses.json b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Responses/responses.json new file mode 100644 index 0000000..304dc87 --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Mocks/Responses/responses.json @@ -0,0 +1,519 @@ +{ + "AccountsEndpointMockData": { + "GetAccount": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "created": "2024-04-05T21:51:12.694Z", + "last_accessed": "2024-04-05T21:51:12.694Z", + "iban": "GL2010440000010445", + "institution_id": "SANDBOXFINANCE_SFIN0000", + "status": "READY", + "owner_name": "John Doe" + }, + "GetBalances": { + "balances": [ + { + "balanceAmount": { + "amount": "1913.12", + "currency": "EUR" + }, + "balanceType": "closingAvailable", + "referenceDate": "2021-11-22" + }, + { + "balanceAmount": { + "amount": "1913.12", + "currency": "EUR" + }, + "balanceType": "forwardAvailable", + "referenceDate": "2021-11-19" + } + ] + }, + "GetAccountDetails": { + "account": { + "resourceId": "abc", + "iban": "GL2010440000010445", + "currency": "EUR", + "ownerName": "Jane Doe", + "name": "Main Account", + "product": "Credit Card", + "cashAccountType": "CACC" + } + }, + "GetTransactions": { + "transactions": { + "booked": [ + { + "transactionId": "string", + "debtorName": "MON MOTHMA", + "debtorAccount": { + "iban": "GL2010440000010445" + }, + "transactionAmount": { + "currency": "EUR", + "amount": "45.00" + }, + "bankTransactionCode": "PMNT", + "bookingDate": "2021-11-19", + "valueDate": "2021-11-19", + "remittanceInformationUnstructured": "For the support of Restoration of the Republic foundation" + }, + { + "transactionId": "string", + "transactionAmount": { + "currency": "string", + "amount": "947.26" + }, + "bankTransactionCode": "string", + "bookingDate": "2021-11-19", + "valueDate": "2021-11-19", + "remittanceInformationUnstructured": "string" + } + ], + "pending": [ + { + "transactionAmount": { + "currency": "string", + "amount": "99.20" + }, + "valueDate": "2021-11-19", + "remittanceInformationUnstructured": "string" + } + ] + } + }, + "GetTransactionRange": { + "transactions": { + "booked": [ + { + "transactionId": "string", + "debtorName": "MON MOTHMA", + "debtorAccount": { + "iban": "GL2010440000010445" + }, + "transactionAmount": { + "currency": "EUR", + "amount": "45.00" + }, + "bankTransactionCode": "PMNT", + "bookingDate": "2021-11-19", + "valueDate": "2021-11-19", + "remittanceInformationUnstructured": "For the support of Restoration of the Republic foundation" + }, + { + "transactionId": "string", + "transactionAmount": { + "currency": "string", + "amount": "947.26" + }, + "bankTransactionCode": "string", + "bookingDate": "2021-11-19", + "valueDate": "2021-11-19", + "remittanceInformationUnstructured": "string" + } + ], + "pending": [ + { + "transactionAmount": { + "currency": "string", + "amount": "99.20" + }, + "valueDate": "2021-11-19", + "remittanceInformationUnstructured": "string" + } + ] + } + }, + "GetAccountWithInvalidGuid": { + "summary": "Invalid Account ID", + "detail": "abcdefg is not a valid Account UUID. ", + "status_code": 400 + }, + "GetAccountThatDoesNotExist": { + "detail": "Not found.", + "summary": "Not found.", + "status_code": 404 + }, + "GetBalancesForAccountThatDoesNotExist": { + "summary": "Account ID f1d53c46-260d-4556-82df-4e5fed58e37c not found", + "detail": "Please check whether you specified a valid Account ID", + "status_code": 404 + }, + "GetTransactionRangeInFuture": { + "date_from": { + "summary": "Date can't be in future", + "detail": "'2024-04-21' can't be greater than 2024-04-20. Specify correct date range" + }, + "date_to": { + "summary": "Date can't be in future", + "detail": "'2024-05-21' can't be greater than 2024-04-20. Specify correct date range" + }, + "status_code": 400 + } + }, + "AgreementsEndpointMockData": { + "GetAgreements": { + "count": 1, + "next": "https://bankaccountdata.gocardless.com/api/v2/agreements/enduser/?limit=100&offset=0", + "previous": "https://bankaccountdata.gocardless.com/api/v2/agreements/enduser/?limit=100&offset=0", + "results": [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "created": "2024-04-08T20:57:00.550Z", + "institution_id": "SANDBOXFINANCE_SFIN0000", + "max_historical_days": 90, + "access_valid_for_days": 90, + "access_scope": [ + "balances", + "details", + "transactions" + ], + "accepted": "2024-04-08T20:57:00.550Z" + } + ] + }, + "GetAgreement": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "created": "2024-04-08T22:54:54.869Z", + "institution_id": "SANDBOXFINANCE_SFIN0000", + "max_historical_days": 90, + "access_valid_for_days": 90, + "access_scope": [ + "balances", + "details", + "transactions" + ], + "accepted": "2024-04-08T22:54:54.869Z" + }, + "CreateAgreement": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa7", + "institution_id": "SANDBOXFINANCE_SFIN0000", + "max_historical_days": 145, + "access_valid_for_days": 145, + "access_scope": [ + "balances", + "details", + "transactions" + ] + }, + "DeleteAgreement": { + "summary": "End User Agreement deleted", + "detail": "End User Agreement bb37bc52-5b1d-44f9-b1cd-ec9594f25387 deleted" + }, + "GetAgreementWithInvalidGuid": { + "summary": "Invalid EndUserAgreement ID", + "detail": "f84d7b8-dee4-4cd9-bc6d-842ef78f6028 is not a valid EndUserAgreement UUID. ", + "status_code": 400 + }, + "CreateAgreementWithInvalidInstitutionId": { + "institution_id": { + "summary": "Unknown Institution ID invalid_institution", + "detail": "Get Institution IDs from /institutions/?country\u003d{$COUNTRY_CODE}" + }, + "status_code": 400 + }, + "CreateAgreementWithInvalidParams": { + "max_historical_days": { + "summary": "Incorrect max_historical_days", + "detail": "max_historical_days must be \u003e 0 and \u003c\u003d SANDBOXFINANCE_SFIN0000 transaction_total_days (90)" + }, + "access_valid_for_days": { + "summary": "Incorrect access_valid_for_days", + "detail": "access_valid_for_days must be \u003e 0 and \u003c\u003d 180" + }, + "access_scope": { + "summary": "Unknown value \u0027[\u0027invalid2\u0027, \u0027invalid\u0027]\u0027 in access_scope", + "detail": "Choose one or several from [\u0027balances\u0027, \u0027details\u0027, \u0027transactions\u0027]" + }, + "status_code": 400 + }, + "CreateAgreementWithEmptyInstitutionIdAndAccessScopes": { + "institution_id": [ + "This field may not be null." + ], + "access_scope": [ + "This field may not be null." + ], + "status_code": 400 + }, + "CreateAgreementWithInvalidParamsAtPolishInstitution": { + "summary": [ + "Institution access scope dependencies error" + ], + "detail": [ + "For this institution the following scopes are required together: [\u0027details\u0027, \u0027balances\u0027]" + ], + "status_code": 400 + } + }, + "InstitutionsEndpointMockData": { + "GetInstitutions": [ + { + "id": "N26_NTSBDEB1", + "name": "N26 Bank", + "bic": "NTSBDEB1", + "transaction_total_days": "90", + "countries": [ + "GB", + "NO", + "SE", + "FI", + "DK", + "EE", + "LV", + "LT", + "NL", + "CZ", + "ES", + "PL", + "BE", + "DE", + "AT", + "BG", + "HR", + "CY", + "FR", + "GR", + "HU", + "IS", + "IE", + "IT", + "LI", + "LU", + "MT", + "PT", + "RO", + "SK", + "SI" + ], + "logo": "https://cdn-logos.gocardless.com/ais/N26_SANDBOX_NTSBDEB1.png", + "identification_codes": [] + }, + { + "id": "N26_NTSBDEB1", + "name": "N26 Bank", + "bic": "NTSBDEB1", + "transaction_total_days": "90", + "countries": [ + "GB", + "NO", + "SE", + "FI", + "DK", + "EE", + "LV", + "LT", + "NL", + "CZ", + "ES", + "PL", + "BE", + "DE", + "AT", + "BG", + "HR", + "CY", + "FR", + "GR", + "HU", + "IS", + "IE", + "IT", + "LI", + "LU", + "MT", + "PT", + "RO", + "SK", + "SI" + ], + "logo": "https://cdn-logos.gocardless.com/ais/N26_SANDBOX_NTSBDEB1.png", + "identification_codes": [] + } + ], + "GetInstitution": { + "id": "N26_NTSBDEB1", + "name": "N26 Bank", + "bic": "NTSBDEB1", + "transaction_total_days": "90", + "countries": [ + "GB", + "NO", + "SE", + "FI", + "DK", + "EE", + "LV", + "LT", + "NL", + "CZ", + "ES", + "PL", + "BE", + "DE", + "AT", + "BG", + "HR", + "CY", + "FR", + "GR", + "HU", + "IS", + "IE", + "IT", + "LI", + "LU", + "MT", + "PT", + "RO", + "SK", + "SI" + ], + "logo": "https://cdn-logos.gocardless.com/ais/N26_SANDBOX_NTSBDEB1.png", + "supported_payments": { + "single-payment": [ + "SCT", + "ISCT" + ] + }, + "supported_features": [ + "account_selection", + "business_accounts", + "card_accounts", + "payments", + "private_accounts" + ], + "identification_codes": [] + }, + "GetInstitutionsForNotCoveredCountry": { + "country": { + "summary": "Invalid country choice.", + "detail": "US is not a valid choice." + }, + "status_code": 400 + }, + "GetNonExistingInstitution": { + "summary": "Not found.", + "detail": "Not found.", + "status_code": 404 + } + }, + "RequisitionsEndpointMockData": { + "GetRequisitions": { + "count": 1, + "next": "https://bankaccountdata.gocardless.com/api/v2/requisitions/?limit=100&offset=0", + "previous": "https://bankaccountdata.gocardless.com/api/v2/requisitions/?limit=100&offset=0", + "results": [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "created": "2024-04-12T23:50:34.962Z", + "redirect": "https://www.robintty.com", + "status": "CR", + "institution_id": "SANDBOXFINANCE_SFIN0000", + "agreement": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "reference": "example-reference", + "accounts": [ + "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "3fa85f64-5717-4562-b3fc-2c963f66afa7" + ], + "user_language": "EN", + "link": "https://ob.nordigen.com/psd2/start/3fa85f64-5717-4562-b3fc-2c963f66afa6/SANDBOXFINANCE_SFIN0000", + "ssn": "555-50-1234", + "account_selection": false, + "redirect_immediate": true + } + ] + }, + "GetRequisition": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "created": "2024-04-12T23:50:34.962Z", + "redirect": "https://www.robintty.com", + "status": "CR", + "institution_id": "SANDBOXFINANCE_SFIN0000", + "agreement": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "reference": "example-reference", + "accounts": [ + "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "3fa85f64-5717-4562-b3fc-2c963f66afa7" + ], + "user_language": "EN", + "link": "https://ob.nordigen.com/psd2/start/3fa85f64-5717-4562-b3fc-2c963f66afa6/SANDBOXFINANCE_SFIN0000", + "ssn": "555-50-1234", + "account_selection": false, + "redirect_immediate": true + }, + "CreateRequisition": { + "redirect": "https://www.robintty.com", + "institution_id": "SANDBOXFINANCE_SFIN0000", + "agreement": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "reference": "example-reference", + "user_language": "EN", + "ssn": "555-50-1234", + "account_selection": false, + "redirect_immediate": true + }, + "DeleteRequisition": { + "summary": "Requisition deleted", + "detail": "Requisition b5462cad-5a7f-42e1-881d-d0fa066f54bc deleted with all its End User Agreements" + }, + "GetRequisitionWithInvalidGuid": { + "summary": "Not found.", + "detail": "Not found.", + "status_code": 404 + }, + "CreateRequisitionWithInvalidId": { + "summary": "Invalid ID", + "detail": "00000000-0000-0000-0000-000000000000 is not a valid UUID. ", + "status_code": 400 + }, + "CreateRequisitionWithInvalidParameters": { + "institution_id": [ + "This field may not be blank." + ], + "agreement": { + "summary": "Incorrect Institution ID ", + "detail": "Provided Institution ID: \u0027\u0027 for requisition does not match EUA institution ID \u0027SANDBOXFINANCE_SFIN0000\u0027. Please provide correct institution ID: \u0027SANDBOXFINANCE_SFIN0000\u0027" + }, + "reference": [ + "This field may not be blank." + ], + "user_language": { + "summary": "Provided user_language is invalid or not supported", + "detail": "\u0027AB\u0027 is an invalid or unsupported language" + }, + "ssn": { + "summary": "SSN verification not supported", + "detail": "SSN verification not supported for " + }, + "account_selection": { + "summary": "Account selection not supported", + "detail": "Account selection not supported for " + }, + "status_code": 400 + } + }, + "TokenEndpointMockData": { + "GetNewToken": { + "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjozMzI3MDExNzU5NH0.gEa5VdPSqZW2xk9IqCEqiw6bzBOer_uAR1yp2XK7FFo", + "access_expires": 86400, + "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MzMyNzAxMTc1OTR9.xfOrczY3KvG-SiHLZkVLPas017ZX8DHkcCN78Xd9cac", + "refresh_expires": 2592000 + }, + "RefreshAccessToken": { + "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjozMzI3MDExNzU5NH0.gEa5VdPSqZW2xk9IqCEqiw6bzBOer_uAR1yp2XK7FFo", + "access_expires": 86400 + } + }, + "CredentialMockData": { + "NoAccountForGivenCredentialsError": { + "summary": "Authentication failed", + "detail": "No active account found with the given credentials", + "status_code": 401 + }, + "IpNotWhitelistedError": { + "summary": "IP address access denied", + "detail": "Your IP 127.0.0.1 isn't whitelisted to perform this action", + "status_code": 403 + } + } +} \ No newline at end of file diff --git a/src/RobinTTY.NordigenApiClient.Tests/NordigenApiClientTests.cs b/src/RobinTTY.NordigenApiClient.Tests/NordigenApiClientTests.cs deleted file mode 100644 index 1bf94e1..0000000 --- a/src/RobinTTY.NordigenApiClient.Tests/NordigenApiClientTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Net; - -namespace RobinTTY.NordigenApiClient.Tests; - -public class NordigenApiClientTests -{ - /// - /// Executes a request to the Nordigen API using the default base address. - /// - [Test] - public async Task ExecuteRequestWithDefaultBaseAddress() - { - var apiClient = TestExtensions.GetConfiguredClient(); - await ExecuteExampleRequest(apiClient); - } - - /// - /// Executes a request to the Nordigen API using a custom base address. - /// - [Test] - public async Task ExecuteRequestWithCustomBaseAddress() - { - var apiClient = TestExtensions.GetConfiguredClient("https://ob.gocardless.com/api/v2/"); - await ExecuteExampleRequest(apiClient); - } - - private async Task ExecuteExampleRequest(NordigenClient apiClient) - { - var response = await apiClient.TokenEndpoint.GetTokenPair(); - TestExtensions.AssertNordigenApiResponseIsSuccessful(response, HttpStatusCode.OK); - var response2 = await apiClient.TokenEndpoint.RefreshAccessToken(response.Result!.RefreshToken); - TestExtensions.AssertNordigenApiResponseIsSuccessful(response2, HttpStatusCode.OK); - } -} \ No newline at end of file diff --git a/src/RobinTTY.NordigenApiClient.Tests/RobinTTY.NordigenApiClient.Tests.csproj b/src/RobinTTY.NordigenApiClient.Tests/RobinTTY.NordigenApiClient.Tests.csproj index e48fae7..13a0390 100644 --- a/src/RobinTTY.NordigenApiClient.Tests/RobinTTY.NordigenApiClient.Tests.csproj +++ b/src/RobinTTY.NordigenApiClient.Tests/RobinTTY.NordigenApiClient.Tests.csproj @@ -9,10 +9,11 @@ + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -30,6 +31,9 @@ PreserveNewest + + Always + diff --git a/src/RobinTTY.NordigenApiClient.Tests/Serialization/CreateAgreementErrorTests.cs b/src/RobinTTY.NordigenApiClient.Tests/Serialization/CreateAgreementErrorTests.cs new file mode 100644 index 0000000..be78afb --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Serialization/CreateAgreementErrorTests.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using RobinTTY.NordigenApiClient.JsonConverters; +using RobinTTY.NordigenApiClient.Models.Errors; + +namespace RobinTTY.NordigenApiClient.Tests.Serialization; + +public class CreateAgreementErrorTests +{ + /// + /// Tests the correct deserialization of . + /// + [Test] + public void DeserializeTransactionWithSingleCurrencyExchange() + { + const string json = "{\n \"summary\": [\n \"Institution access scope dependencies error\",\n \"Some Other Error Summary\"\n ],\n \"detail\": [\n \"For this institution the following scopes are required together: [\\u0027details\\u0027, \\u0027balances\\u0027]\",\n \"Some Other Error Detail\"\n ],\n \"status_code\": 400\n}\n"; + + var options = new JsonSerializerOptions + { + Converters = {new StringArrayMergeConverter()} + }; + var createAgreementError = JsonSerializer.Deserialize(json, options); + + Assert.Multiple(() => + { + Assert.That(createAgreementError!.Summary, Is.Not.Null); + Assert.That(createAgreementError.Detail, Is.Not.Null); + Assert.That(createAgreementError.Summary, Is.EqualTo("Institution access scope dependencies error; Some Other Error Summary")); + Assert.That(createAgreementError.Detail, Is.EqualTo("For this institution the following scopes are required together: ['details', 'balances']; Some Other Error Detail")); + }); + } +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Serialization/TransactionTests.cs b/src/RobinTTY.NordigenApiClient.Tests/Serialization/TransactionTests.cs index 431eba6..5cf2a0c 100644 --- a/src/RobinTTY.NordigenApiClient.Tests/Serialization/TransactionTests.cs +++ b/src/RobinTTY.NordigenApiClient.Tests/Serialization/TransactionTests.cs @@ -1,5 +1,4 @@ -using System.Net; -using System.Runtime.Serialization; +using System.Runtime.Serialization; using System.Text.Json; using RobinTTY.NordigenApiClient.JsonConverters; using RobinTTY.NordigenApiClient.Models.Errors; @@ -66,7 +65,6 @@ public void DeserializeTransactionWithMultipleCurrencyExchange() /// /// Tests that a malformed json throws a human readable error containing the raw json content of the API response. /// - /// [Test] public async Task DeserializeWithException() { diff --git a/src/RobinTTY.NordigenApiClient.Tests/TestExtensions.cs b/src/RobinTTY.NordigenApiClient.Tests/Shared/AssertionHelpers.cs similarity index 50% rename from src/RobinTTY.NordigenApiClient.Tests/TestExtensions.cs rename to src/RobinTTY.NordigenApiClient.Tests/Shared/AssertionHelpers.cs index 91de31e..0f33bc2 100644 --- a/src/RobinTTY.NordigenApiClient.Tests/TestExtensions.cs +++ b/src/RobinTTY.NordigenApiClient.Tests/Shared/AssertionHelpers.cs @@ -1,11 +1,8 @@ -using System.Net; -using RobinTTY.NordigenApiClient.Endpoints; -using RobinTTY.NordigenApiClient.Models; -using RobinTTY.NordigenApiClient.Models.Responses; +using RobinTTY.NordigenApiClient.Models.Responses; -namespace RobinTTY.NordigenApiClient.Tests; +namespace RobinTTY.NordigenApiClient.Tests.Shared; -internal static class TestExtensions +public static class AssertionHelpers { internal static void AssertNordigenApiResponseIsSuccessful( NordigenApiResponse response, HttpStatusCode statusCode) @@ -32,13 +29,26 @@ internal static void AssertNordigenApiResponseIsUnsuccessful( Assert.That(response.StatusCode, Is.EqualTo(statusCode)); }); } - - internal static NordigenClient GetConfiguredClient(string? baseAddress = null) + + internal static void AssertThatAgreementPageContainsAgreement( + NordigenApiResponse, BasicResponse> pagedResponse, List ids) { - var address = baseAddress ?? NordigenEndpointUrls.Base; - var httpClient = new HttpClient {BaseAddress = new Uri(address)}; - var secrets = File.ReadAllLines("secrets.txt"); - var credentials = new NordigenClientCredentials(secrets[0], secrets[1]); - return new NordigenClient(httpClient, credentials); + AssertNordigenApiResponseIsSuccessful(pagedResponse, HttpStatusCode.OK); + var page2Result = pagedResponse.Result!; + var page2Agreements = page2Result.Results.ToList(); + Assert.Multiple(() => + { + Assert.That(page2Agreements, Has.Count.EqualTo(1)); + Assert.That(ids, Does.Contain(page2Agreements.First().Id.ToString())); + }); + } + + internal static void AssertBasicResponseMatchesExpectations(BasicResponse? response, string summary, string detail) + { + Assert.Multiple(() => + { + Assert.That(response?.Summary, Is.EqualTo(summary)); + Assert.That(response?.Detail, Is.EqualTo(detail)); + }); } } diff --git a/src/RobinTTY.NordigenApiClient.Tests/Shared/TestHelpers.cs b/src/RobinTTY.NordigenApiClient.Tests/Shared/TestHelpers.cs new file mode 100644 index 0000000..f59d87a --- /dev/null +++ b/src/RobinTTY.NordigenApiClient.Tests/Shared/TestHelpers.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using FakeItEasy; +using RobinTTY.NordigenApiClient.Endpoints; +using RobinTTY.NordigenApiClient.JsonConverters; +using RobinTTY.NordigenApiClient.Models; +using RobinTTY.NordigenApiClient.Tests.Mocks; +using RobinTTY.NordigenApiClient.Tests.Mocks.Responses; + +namespace RobinTTY.NordigenApiClient.Tests.Shared; + +internal static class TestHelpers +{ + private static readonly JsonSerializerOptions JsonSerializerOptions; + public static readonly string[] Secrets = File.ReadAllLines("secrets.txt"); + public static MockResponsesModel MockData { get; } + + static TestHelpers() + { + var json = File.ReadAllText("Mocks/Responses/responses.json"); + JsonSerializerOptions = new JsonSerializerOptions + { + Converters = + { + new JsonWebTokenConverter(), new GuidConverter(), + new CultureSpecificDecimalConverter() + } + }; + MockData = JsonSerializer.Deserialize(json, JsonSerializerOptions) ?? + throw new InvalidOperationException("Could not deserialize mock Data"); + } + + internal static NordigenClient GetConfiguredClient(string? baseAddress = null) + { + var address = baseAddress ?? NordigenEndpointUrls.Base; + var httpClient = new HttpClient(); + httpClient.BaseAddress = new Uri(address); + var credentials = new NordigenClientCredentials(Secrets[0], Secrets[1]); + return new NordigenClient(httpClient, credentials); + } + + internal static NordigenClient GetMockClient(object value, HttpStatusCode statusCode, + bool addDefaultAuthToken = true) => GetMockClient([new ValueTuple(value, statusCode)], + addDefaultAuthToken); + + private static NordigenClient GetMockClient(List<(object Value, HttpStatusCode StatusCode)> responses, + bool addDefaultAuthToken = true) + { + var fakeHttpMessageHandler = A.Fake(); + var httpResponseMessages = new List(); + var token = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + $"{{\n \"access\": \"{Secrets[4]}\",\n \"access_expires\": 86400,\n" + + $" \"refresh\": \"{Secrets[5]}\",\n \"refresh_expires\": 2592000\n}}") + }; + + if (addDefaultAuthToken) + httpResponseMessages.Add(token); + + responses.ForEach(response => + { + var responsePayload = JsonSerializer.Serialize(response.Value, JsonSerializerOptions); + httpResponseMessages.Add( + new HttpResponseMessage + { + StatusCode = response.StatusCode, + Content = new StringContent(responsePayload) + } + ); + }); + + A.CallTo(() => + fakeHttpMessageHandler.FakeSendAsync(A.Ignored, A.Ignored)) + .ReturnsNextFromSequence(httpResponseMessages.ToArray()); + + var mockHttpClient = new HttpClient(fakeHttpMessageHandler); + var credentials = new NordigenClientCredentials(Secrets[0], Secrets[1]); + return new NordigenClient(mockHttpClient, credentials); + } +} diff --git a/src/RobinTTY.NordigenApiClient.Tests/Usings.cs b/src/RobinTTY.NordigenApiClient.Tests/Usings.cs index a076aea..6c7f844 100644 --- a/src/RobinTTY.NordigenApiClient.Tests/Usings.cs +++ b/src/RobinTTY.NordigenApiClient.Tests/Usings.cs @@ -1,2 +1,3 @@ -global using NUnit.Framework; +global using System.Net; global using System.Net.Http; +global using NUnit.Framework; diff --git a/src/RobinTTY.NordigenApiClient/Contracts/IAccountsEndpoint.cs b/src/RobinTTY.NordigenApiClient/Contracts/IAccountsEndpoint.cs index 0ab7329..fcc3e64 100644 --- a/src/RobinTTY.NordigenApiClient/Contracts/IAccountsEndpoint.cs +++ b/src/RobinTTY.NordigenApiClient/Contracts/IAccountsEndpoint.cs @@ -17,7 +17,7 @@ public interface IAccountsEndpoint /// The id of the account to get. /// Optional token to signal cancellation of the operation. /// A which contains the specified account. - Task> GetAccount(Guid id, + Task> GetAccount(Guid id, CancellationToken cancellationToken = default); /// @@ -26,7 +26,7 @@ Task> GetAccount(Guid id, /// The id of the account to get. /// Optional token to signal cancellation of the operation. /// A which contains the specified account. - Task> GetAccount(string id, + Task> GetAccount(string id, CancellationToken cancellationToken = default); /// diff --git a/src/RobinTTY.NordigenApiClient/Contracts/IAgreementsEndpoint.cs b/src/RobinTTY.NordigenApiClient/Contracts/IAgreementsEndpoint.cs index e6666c2..ef61760 100644 --- a/src/RobinTTY.NordigenApiClient/Contracts/IAgreementsEndpoint.cs +++ b/src/RobinTTY.NordigenApiClient/Contracts/IAgreementsEndpoint.cs @@ -22,7 +22,7 @@ public interface IAgreementsEndpoint /// A containing a which /// contains a list of end user agreements. /// - Task, BasicError>> GetAgreements(int limit, int offset, + Task, BasicResponse>> GetAgreements(int limit, int offset, CancellationToken cancellationToken = default); /// @@ -31,7 +31,7 @@ Task, BasicError>> GetAgreements(int /// The id of the agreement to retrieve. /// Optional token to signal cancellation of the operation. /// A which contains the specified end user agreements. - Task> GetAgreement(Guid id, + Task> GetAgreement(Guid id, CancellationToken cancellationToken = default); /// @@ -40,7 +40,7 @@ Task> GetAgreement(Guid id, /// The id of the agreement to retrieve. /// Optional token to signal cancellation of the operation. /// A which contains the specified end user agreements. - Task> GetAgreement(string id, + Task> GetAgreement(string id, CancellationToken cancellationToken = default); /// @@ -58,7 +58,7 @@ Task> CreateAgreement( /// The id of the agreement to delete. /// Optional token to signal cancellation of the operation. /// A containing a confirmation of the deletion. - Task> DeleteAgreement(Guid id, + Task> DeleteAgreement(Guid id, CancellationToken cancellationToken = default); /// @@ -67,7 +67,7 @@ Task> DeleteAgreement(Guid id, /// The id of the agreement to delete. /// Optional token to signal cancellation of the operation. /// A containing a confirmation of the deletion. - Task> DeleteAgreement(string id, + Task> DeleteAgreement(string id, CancellationToken cancellationToken = default); /// @@ -76,8 +76,8 @@ Task> DeleteAgreement(string id, /// The id of the end user agreement to accept. /// The metadata required to accept the end user agreement. /// Optional token to signal cancellation of the operation. - /// - Task> AcceptAgreement(Guid id, + /// A which contains the accepted end user agreement. + Task> AcceptAgreement(Guid id, AcceptAgreementRequest metadata, CancellationToken cancellationToken = default); /// @@ -86,7 +86,7 @@ Task> AcceptAgreement(Guid id, /// The id of the end user agreement to accept. /// The metadata required to accept the end user agreement. /// Optional token to signal cancellation of the operation. - /// - Task> AcceptAgreement(string id, + /// A which contains the accepted end user agreement. + Task> AcceptAgreement(string id, AcceptAgreementRequest metadata, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/RobinTTY.NordigenApiClient/Contracts/IInstitutionsEndpoint.cs b/src/RobinTTY.NordigenApiClient/Contracts/IInstitutionsEndpoint.cs index 24d6515..7838c91 100644 --- a/src/RobinTTY.NordigenApiClient/Contracts/IInstitutionsEndpoint.cs +++ b/src/RobinTTY.NordigenApiClient/Contracts/IInstitutionsEndpoint.cs @@ -68,7 +68,7 @@ public interface IInstitutionsEndpoint /// A containing a list of supported institutions if the /// request was successful. /// - Task, InstitutionsError>> GetInstitutions(string? country = null, + Task, BasicResponse>> GetInstitutions(string? country = null, bool? accessScopesSupported = null, bool? accountSelectionSupported = null, bool? businessAccountsSupported = null, bool? cardAccountsSupported = null, bool? corporateAccountsSupported = null, @@ -86,6 +86,6 @@ Task, InstitutionsError>> GetInstitutions( /// A containing the institution matching the id if the /// request was successful. /// - Task> GetInstitution(string id, + Task> GetInstitution(string id, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/RobinTTY.NordigenApiClient/Contracts/IRequisitionsEndpoint.cs b/src/RobinTTY.NordigenApiClient/Contracts/IRequisitionsEndpoint.cs index 02d9017..38e99a0 100644 --- a/src/RobinTTY.NordigenApiClient/Contracts/IRequisitionsEndpoint.cs +++ b/src/RobinTTY.NordigenApiClient/Contracts/IRequisitionsEndpoint.cs @@ -22,7 +22,7 @@ public interface IRequisitionsEndpoint /// A containing a which /// contains a list of requisitions. /// - Task, BasicError>> GetRequisitions(int limit, int offset, + Task, BasicResponse>> GetRequisitions(int limit, int offset, CancellationToken cancellationToken = default); /// @@ -31,7 +31,7 @@ Task, BasicError>> GetRequisitions /// The id of the requisition to retrieve. /// Optional token to signal cancellation of the operation. /// A which contains the specified requisition. - Task> GetRequisition(Guid id, + Task> GetRequisition(Guid id, CancellationToken cancellationToken = default); /// @@ -40,7 +40,7 @@ Task> GetRequisition(Guid id, /// The id of the requisition to retrieve. /// Optional token to signal cancellation of the operation. /// A which contains the specified requisition. - Task> GetRequisition(string id, + Task> GetRequisition(string id, CancellationToken cancellationToken = default); /// @@ -58,7 +58,7 @@ Task> CreateRequisition /// The id of the requisition to delete. /// Optional token to signal cancellation of the operation. /// A containing a confirmation of the deletion. - Task> DeleteRequisition(Guid id, + Task> DeleteRequisition(Guid id, CancellationToken cancellationToken = default); /// @@ -67,6 +67,6 @@ Task> DeleteRequisition(Guid id, /// The id of the requisition to delete. /// Optional token to signal cancellation of the operation. /// A containing a confirmation of the deletion. - Task> DeleteRequisition(string id, + Task> DeleteRequisition(string id, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/RobinTTY.NordigenApiClient/Contracts/ITokenEndpoint.cs b/src/RobinTTY.NordigenApiClient/Contracts/ITokenEndpoint.cs index 6e0a4df..f403d01 100644 --- a/src/RobinTTY.NordigenApiClient/Contracts/ITokenEndpoint.cs +++ b/src/RobinTTY.NordigenApiClient/Contracts/ITokenEndpoint.cs @@ -22,7 +22,7 @@ public interface ITokenEndpoint /// A containing the obtained /// if the request was successful. /// - Task> GetTokenPair( + Task> GetTokenPair( CancellationToken cancellationToken = default); /// @@ -34,6 +34,6 @@ Task> GetTokenPair( /// A containing the refreshed /// if the request was successful. /// - Task> RefreshAccessToken(JsonWebToken refreshToken, + Task> RefreshAccessToken(JsonWebToken refreshToken, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/RobinTTY.NordigenApiClient/Endpoints/AccountsEndpoint.cs b/src/RobinTTY.NordigenApiClient/Endpoints/AccountsEndpoint.cs index d175849..ca07df3 100644 --- a/src/RobinTTY.NordigenApiClient/Endpoints/AccountsEndpoint.cs +++ b/src/RobinTTY.NordigenApiClient/Endpoints/AccountsEndpoint.cs @@ -17,20 +17,20 @@ public class AccountsEndpoint : IAccountsEndpoint internal AccountsEndpoint(NordigenClient client) => _nordigenClient = client; /// - public async Task> GetAccount(Guid id, + public async Task> GetAccount(Guid id, CancellationToken cancellationToken = default) => await GetAccountInternal(id.ToString(), cancellationToken); /// - public async Task> GetAccount(string id, + public async Task> GetAccount(string id, CancellationToken cancellationToken = default) => await GetAccountInternal(id, cancellationToken); - private async Task> GetAccountInternal(string id, + private async Task> GetAccountInternal(string id, CancellationToken cancellationToken) { - return await _nordigenClient.MakeRequest( + return await _nordigenClient.MakeRequest( $"{NordigenEndpointUrls.AccountsEndpoint}{id}/", HttpMethod.Get, cancellationToken); } @@ -102,6 +102,10 @@ private async Task> GetT DateTime? startDate, DateTime? endDate, CancellationToken cancellationToken) #endif { + if (startDate > endDate) + throw new ArgumentException( + $"Starting date '{startDate}' is greater than end date '{endDate}'. When specifying date range, starting date must precede the end date."); + var query = new List>(); if (startDate != null) query.Add(new KeyValuePair("date_from", DateToIso8601(startDate.Value))); diff --git a/src/RobinTTY.NordigenApiClient/Endpoints/AgreementsEndpoint.cs b/src/RobinTTY.NordigenApiClient/Endpoints/AgreementsEndpoint.cs index c3c882a..408a7a5 100644 --- a/src/RobinTTY.NordigenApiClient/Endpoints/AgreementsEndpoint.cs +++ b/src/RobinTTY.NordigenApiClient/Endpoints/AgreementsEndpoint.cs @@ -18,28 +18,28 @@ public class AgreementsEndpoint : IAgreementsEndpoint internal AgreementsEndpoint(NordigenClient client) => _nordigenClient = client; /// - public async Task, BasicError>> GetAgreements(int limit, int offset, + public async Task, BasicResponse>> GetAgreements(int limit, int offset, CancellationToken cancellationToken = default) { var query = new KeyValuePair[] {new("limit", limit.ToString()), new("offset", offset.ToString())}; - return await _nordigenClient.MakeRequest, BasicError>( + return await _nordigenClient.MakeRequest, BasicResponse>( NordigenEndpointUrls.AgreementsEndpoint, HttpMethod.Get, cancellationToken, query); } /// - public async Task> GetAgreement(Guid id, + public async Task> GetAgreement(Guid id, CancellationToken cancellationToken = default) => await GetAgreementInternal(id.ToString(), cancellationToken); /// - public async Task> GetAgreement(string id, + public async Task> GetAgreement(string id, CancellationToken cancellationToken = default) => await GetAgreementInternal(id, cancellationToken); - private async Task> GetAgreementInternal(string id, + private async Task> GetAgreementInternal(string id, CancellationToken cancellationToken) => - await _nordigenClient.MakeRequest( + await _nordigenClient.MakeRequest( $"{NordigenEndpointUrls.AgreementsEndpoint}{id}/", HttpMethod.Get, cancellationToken); /// @@ -52,37 +52,37 @@ public async Task> CreateAg } /// - public async Task> DeleteAgreement(Guid id, + public async Task> DeleteAgreement(Guid id, CancellationToken cancellationToken = default) => await DeleteAgreementInternal(id.ToString(), cancellationToken); /// - public async Task> DeleteAgreement(string id, + public async Task> DeleteAgreement(string id, CancellationToken cancellationToken = default) => await DeleteAgreementInternal(id, cancellationToken); - private async Task> DeleteAgreementInternal(string id, + private async Task> DeleteAgreementInternal(string id, CancellationToken cancellationToken) { - return await _nordigenClient.MakeRequest( + return await _nordigenClient.MakeRequest( $"{NordigenEndpointUrls.AgreementsEndpoint}{id}/", HttpMethod.Delete, cancellationToken); } /// - public async Task> AcceptAgreement(Guid id, + public async Task> AcceptAgreement(Guid id, AcceptAgreementRequest metadata, CancellationToken cancellationToken = default) => await AcceptAgreementInternal(id.ToString(), metadata, cancellationToken); /// - public async Task> AcceptAgreement(string id, + public async Task> AcceptAgreement(string id, AcceptAgreementRequest metadata, CancellationToken cancellationToken = default) => await AcceptAgreementInternal(id, metadata, cancellationToken); - private async Task> AcceptAgreementInternal(string id, + private async Task> AcceptAgreementInternal(string id, AcceptAgreementRequest metadata, CancellationToken cancellationToken) { var body = JsonContent.Create(metadata); - return await _nordigenClient.MakeRequest( + return await _nordigenClient.MakeRequest( $"{NordigenEndpointUrls.AgreementsEndpoint}{id}/accept/", HttpMethod.Put, cancellationToken, body: body); } } diff --git a/src/RobinTTY.NordigenApiClient/Endpoints/InstitutionsEndpoint.cs b/src/RobinTTY.NordigenApiClient/Endpoints/InstitutionsEndpoint.cs index 202958f..9acc769 100644 --- a/src/RobinTTY.NordigenApiClient/Endpoints/InstitutionsEndpoint.cs +++ b/src/RobinTTY.NordigenApiClient/Endpoints/InstitutionsEndpoint.cs @@ -16,7 +16,7 @@ public class InstitutionsEndpoint : IInstitutionsEndpoint internal InstitutionsEndpoint(NordigenClient client) => _nordigenClient = client; /// - public async Task, InstitutionsError>> GetInstitutions(string? country = null, + public async Task, BasicResponse>> GetInstitutions(string? country = null, bool? accessScopesSupported = null, bool? accountSelectionSupported = null, bool? businessAccountsSupported = null, bool? cardAccountsSupported = null, bool? corporateAccountsSupported = null, @@ -27,6 +27,8 @@ public async Task, InstitutionsError>> Get { var query = new List>(); if (country != null) query.Add(new KeyValuePair("country", country)); + + // Add any required query parameter if (accessScopesSupported.HasValue) query.Add(GetSupportFlagQuery("access_scopes_supported", accessScopesSupported.Value)); if (accountSelectionSupported.HasValue) @@ -48,16 +50,20 @@ public async Task, InstitutionsError>> Get query.Add(GetSupportFlagQuery("pending_transactions_supported", pendingTransactionsSupported.Value)); if (ssnVerificationSupported.HasValue) query.Add(GetSupportFlagQuery("ssn_verification_supported", ssnVerificationSupported.Value)); - return await _nordigenClient.MakeRequest, InstitutionsError>( + + var response = await _nordigenClient.MakeRequest, InstitutionsErrorInternal>( NordigenEndpointUrls.InstitutionsEndpoint, HttpMethod.Get, cancellationToken, query); + + return new NordigenApiResponse, BasicResponse>(response.StatusCode, response.IsSuccess, response.Result, + response.Error); } private static KeyValuePair GetSupportFlagQuery(string flag, bool value) => new(flag, value.ToString().ToLower()); /// - public async Task> GetInstitution(string id, + public async Task> GetInstitution(string id, CancellationToken cancellationToken = default) => - await _nordigenClient.MakeRequest( + await _nordigenClient.MakeRequest( $"{NordigenEndpointUrls.InstitutionsEndpoint}{id}/", HttpMethod.Get, cancellationToken); } diff --git a/src/RobinTTY.NordigenApiClient/Endpoints/RequisitionsEndpoint.cs b/src/RobinTTY.NordigenApiClient/Endpoints/RequisitionsEndpoint.cs index 96a34d0..1355cc4 100644 --- a/src/RobinTTY.NordigenApiClient/Endpoints/RequisitionsEndpoint.cs +++ b/src/RobinTTY.NordigenApiClient/Endpoints/RequisitionsEndpoint.cs @@ -18,28 +18,28 @@ public class RequisitionsEndpoint : IRequisitionsEndpoint internal RequisitionsEndpoint(NordigenClient client) => _nordigenClient = client; /// - public async Task, BasicError>> GetRequisitions(int limit, int offset, + public async Task, BasicResponse>> GetRequisitions(int limit, int offset, CancellationToken cancellationToken = default) { var query = new KeyValuePair[] {new("limit", limit.ToString()), new("offset", offset.ToString())}; - return await _nordigenClient.MakeRequest, BasicError>( + return await _nordigenClient.MakeRequest, BasicResponse>( NordigenEndpointUrls.RequisitionsEndpoint, HttpMethod.Get, cancellationToken, query); } /// - public async Task> GetRequisition(Guid id, + public async Task> GetRequisition(Guid id, CancellationToken cancellationToken = default) => await GetRequisitionInternal(id.ToString(), cancellationToken); /// - public async Task> GetRequisition(string id, + public async Task> GetRequisition(string id, CancellationToken cancellationToken = default) => await GetRequisitionInternal(id, cancellationToken); - private async Task> GetRequisitionInternal(string id, + private async Task> GetRequisitionInternal(string id, CancellationToken cancellationToken = default) => - await _nordigenClient.MakeRequest( + await _nordigenClient.MakeRequest( $"{NordigenEndpointUrls.RequisitionsEndpoint}{id}/", HttpMethod.Get, cancellationToken); /// @@ -52,17 +52,17 @@ public async Task> Crea } /// - public async Task> DeleteRequisition(Guid id, + public async Task> DeleteRequisition(Guid id, CancellationToken cancellationToken = default) => await DeleteRequisitionInternal(id.ToString(), cancellationToken); /// - public async Task> DeleteRequisition(string id, + public async Task> DeleteRequisition(string id, CancellationToken cancellationToken = default) => await DeleteRequisitionInternal(id, cancellationToken); - private async Task> DeleteRequisitionInternal(string id, + private async Task> DeleteRequisitionInternal(string id, CancellationToken cancellationToken) => - await _nordigenClient.MakeRequest( + await _nordigenClient.MakeRequest( $"{NordigenEndpointUrls.RequisitionsEndpoint}{id}/", HttpMethod.Delete, cancellationToken); } diff --git a/src/RobinTTY.NordigenApiClient/Endpoints/TokenEndpoint.cs b/src/RobinTTY.NordigenApiClient/Endpoints/TokenEndpoint.cs index c3927be..45423a6 100644 --- a/src/RobinTTY.NordigenApiClient/Endpoints/TokenEndpoint.cs +++ b/src/RobinTTY.NordigenApiClient/Endpoints/TokenEndpoint.cs @@ -19,21 +19,21 @@ public class TokenEndpoint : ITokenEndpoint internal TokenEndpoint(NordigenClient client) => _nordigenClient = client; /// - public async Task> GetTokenPair( + public async Task> GetTokenPair( CancellationToken cancellationToken = default) { var requestBody = JsonContent.Create(_nordigenClient.Credentials); - return await _nordigenClient.MakeRequest( + return await _nordigenClient.MakeRequest( $"{NordigenEndpointUrls.TokensEndpoint}new/", HttpMethod.Post, cancellationToken, body: requestBody, useAuthentication: false); } /// - public async Task> RefreshAccessToken(JsonWebToken refreshToken, + public async Task> RefreshAccessToken(JsonWebToken refreshToken, CancellationToken cancellationToken = default) { var requestBody = JsonContent.Create(new {refresh = refreshToken.EncodedToken}); - return await _nordigenClient.MakeRequest( + return await _nordigenClient.MakeRequest( $"{NordigenEndpointUrls.TokensEndpoint}refresh/", HttpMethod.Post, cancellationToken, body: requestBody, useAuthentication: false); } diff --git a/src/RobinTTY.NordigenApiClient/JsonConverters/InstitutionsErrorConverter.cs b/src/RobinTTY.NordigenApiClient/JsonConverters/InstitutionsErrorConverter.cs deleted file mode 100644 index 1fbe74e..0000000 --- a/src/RobinTTY.NordigenApiClient/JsonConverters/InstitutionsErrorConverter.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Runtime.Serialization; -using System.Text.Json; -using System.Text.Json.Serialization; -using RobinTTY.NordigenApiClient.Models.Errors; - -namespace RobinTTY.NordigenApiClient.JsonConverters; - -internal class InstitutionsErrorConverter : JsonConverter -{ - public override InstitutionsError Read(ref Utf8JsonReader reader, Type typeToConvert, - JsonSerializerOptions? options) - { - var error = JsonSerializer.Deserialize(ref reader, options); - if (error is not null) - return error.Country is not null - ? new InstitutionsError(error.Country) - : new InstitutionsError(new BasicError(error.Summary!, error.Detail!)); - throw new SerializationException( - "Couldn't deserialize institutions error, please report this issue to the library author: https://github.com/RobinTTY/NordigenApiClient/issues."); - } - - public override void Write(Utf8JsonWriter writer, InstitutionsError value, JsonSerializerOptions options) - { - writer.WriteStringValue(value.ToString()); - } -} diff --git a/src/RobinTTY.NordigenApiClient/JsonConverters/SingleOrArrayConverter.cs b/src/RobinTTY.NordigenApiClient/JsonConverters/SingleOrArrayConverter.cs index 097c306..bfd23da 100644 --- a/src/RobinTTY.NordigenApiClient/JsonConverters/SingleOrArrayConverter.cs +++ b/src/RobinTTY.NordigenApiClient/JsonConverters/SingleOrArrayConverter.cs @@ -3,35 +3,34 @@ namespace RobinTTY.NordigenApiClient.JsonConverters; -internal class SingleOrArrayConverter : JsonConverter - where TEnumerable : IEnumerable +internal class SingleOrArrayConverter : JsonConverter + where TCollection : class, ICollection, new() { - public override TEnumerable? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override TCollection Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { switch (reader.TokenType) { case JsonTokenType.Null: - return (TEnumerable) Enumerable.Empty(); + return []; case JsonTokenType.StartArray: - var list = new List(); + var collection = new TCollection(); while (reader.Read()) { - if (reader.TokenType == JsonTokenType.EndArray) break; + if (reader.TokenType is JsonTokenType.EndArray) break; var listItem = JsonSerializer.Deserialize(ref reader, options); - if (listItem != null) list.Add(listItem); + if (listItem != null) collection.Add(listItem); } - return (TEnumerable) (IEnumerable) list; + + return collection; default: var item = JsonSerializer.Deserialize(ref reader, options); - return item != null - ? (TEnumerable) (IEnumerable) new List {item} - : (TEnumerable) Enumerable.Empty(); + return item != null ? [item] : []; } } - public override void Write(Utf8JsonWriter writer, TEnumerable value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, TCollection value, JsonSerializerOptions options) { - if (value.Count() == 1) + if (value.Count == 1) { JsonSerializer.Serialize(writer, value.First(), options); } @@ -43,4 +42,4 @@ public override void Write(Utf8JsonWriter writer, TEnumerable value, JsonSeriali writer.WriteEndArray(); } } -} \ No newline at end of file +} diff --git a/src/RobinTTY.NordigenApiClient/JsonConverters/StringArrayConverters.cs b/src/RobinTTY.NordigenApiClient/JsonConverters/StringArrayConverters.cs new file mode 100644 index 0000000..720480b --- /dev/null +++ b/src/RobinTTY.NordigenApiClient/JsonConverters/StringArrayConverters.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using RobinTTY.NordigenApiClient.Models.Requests; +using RobinTTY.NordigenApiClient.Models.Responses; + +namespace RobinTTY.NordigenApiClient.JsonConverters; + +/// +/// For some errors the GoCardless API returns arrays for Summary/Detail properties inside the . +/// I've never actually seen them contain multiple values, but this converter merges them into one string so that the +/// can stay as simple as possible. +/// +internal class StringArrayMergeConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + case JsonTokenType.StartArray: + var list = new List(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) break; + var listItem = reader.GetString(); + if (listItem != null) list.Add(listItem); + } + + return string.Join("; ", list); + default: + return JsonSerializer.Deserialize(ref reader, options); + } + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, value, options); +} + +/// +/// For some errors (so far only seen when creating requisitions) the GoCardless API returns a simple array of strings +/// as response to a field in the having an invalid value. To bring them in line +/// with errors from other fields in the response this converter converts them to the type. +/// +internal class StringArrayToBasicResponseConverter : JsonConverter +{ + public override BasicResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + case JsonTokenType.StartArray: + var list = new List(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) break; + var listItem = reader.GetString(); + if (listItem != null) list.Add(listItem); + } + + return new BasicResponse(list.First(), string.Join("; ", list)); + default: + return JsonSerializer.Deserialize(ref reader, options); + } + } + + public override void Write(Utf8JsonWriter writer, BasicResponse value, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, value, options); +} diff --git a/src/RobinTTY.NordigenApiClient/Models/Errors/AccountsError.cs b/src/RobinTTY.NordigenApiClient/Models/Errors/AccountsError.cs index da513d7..444f886 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Errors/AccountsError.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Errors/AccountsError.cs @@ -1,18 +1,20 @@ using System.Text.Json.Serialization; using RobinTTY.NordigenApiClient.Endpoints; +using RobinTTY.NordigenApiClient.Models.Responses; namespace RobinTTY.NordigenApiClient.Models.Errors; /// /// An error description as returned by some operations of the accounts endpoint of the Nordigen API. /// -public class AccountsError : BasicError +public class AccountsError : BasicResponse { /// /// The type of the error. /// [JsonPropertyName("type")] public string? Type { get; } + #if NET6_0_OR_GREATER /// /// An error that was returned related to the @@ -29,7 +31,8 @@ public class AccountsError : BasicError /// #endif [JsonPropertyName("date_from")] - public BasicError? StartDateError { get; } + public BasicResponse? StartDateError { get; } + #if NET6_0_OR_GREATER /// /// An error that was returned related to the @@ -46,7 +49,13 @@ public class AccountsError : BasicError /// #endif [JsonPropertyName("date_to")] - public BasicError? EndDateError { get; } + public BasicResponse? EndDateError { get; } + + /// + /// Creates a new instance of . + /// + public AccountsError(){} + #if NET6_0_OR_GREATER /// /// Creates a new instance of . @@ -87,8 +96,8 @@ public class AccountsError : BasicError /// #endif [JsonConstructor] - public AccountsError(string summary, string detail, string? type, BasicError? startDateError, - BasicError? endDateError) : base(summary, detail) + public AccountsError(string summary, string detail, string? type, BasicResponse? startDateError, + BasicResponse? endDateError) : base(summary, detail) { Type = type; StartDateError = startDateError; diff --git a/src/RobinTTY.NordigenApiClient/Models/Errors/BasicError.cs b/src/RobinTTY.NordigenApiClient/Models/Errors/BasicError.cs deleted file mode 100644 index c9bdc98..0000000 --- a/src/RobinTTY.NordigenApiClient/Models/Errors/BasicError.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json.Serialization; - -namespace RobinTTY.NordigenApiClient.Models.Errors; - -/// -/// A basic error description returned by the Nordigen API. -/// -public class BasicError -{ - /// - /// The summary of the API error. - /// - [JsonPropertyName("summary")] - public string Summary { get; } - - /// - /// The detailed description of the API error. - /// - [JsonPropertyName("detail")] - public string Detail { get; } - - /// - /// Creates a new instance of . - /// - /// The summary of the API error. - /// The detailed description of the API error. - [JsonConstructor] - public BasicError(string summary, string detail) - { - Summary = summary; - Detail = detail; - } -} diff --git a/src/RobinTTY.NordigenApiClient/Models/Errors/CreateAgreementError.cs b/src/RobinTTY.NordigenApiClient/Models/Errors/CreateAgreementError.cs index 6e347e8..54b3317 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Errors/CreateAgreementError.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Errors/CreateAgreementError.cs @@ -1,47 +1,59 @@ using System.Text.Json.Serialization; +using RobinTTY.NordigenApiClient.JsonConverters; using RobinTTY.NordigenApiClient.Models.Requests; +using RobinTTY.NordigenApiClient.Models.Responses; namespace RobinTTY.NordigenApiClient.Models.Errors; /// /// An error description as returned by the create operation of the agreements endpoint of the Nordigen API. /// -public class CreateAgreementError : BasicError +public class CreateAgreementError : BasicResponse { /// /// An error that was returned related to the property sent during /// the request. /// [JsonPropertyName("institution_id")] - public BasicError? InstitutionIdError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? InstitutionIdError { get; } /// /// An error that was returned related to the property sent during /// the request. /// [JsonPropertyName("access_scope")] - public BasicError? AccessScopeError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? AccessScopeError { get; } /// /// An error that was returned related to the property sent /// during the request. /// [JsonPropertyName("max_historical_days")] - public BasicError? MaxHistoricalDaysError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? MaxHistoricalDaysError { get; } /// /// An error that was returned related to the property sent /// during the request. /// [JsonPropertyName("access_valid_for_days")] - public BasicError? AccessValidForDaysError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? AccessValidForDaysError { get; } /// /// An error that was returned related to the property sent during /// the request. /// [JsonPropertyName("agreement")] - public BasicError? AgreementError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? AgreementError { get; } + + /// + /// Creates a new instance of . + /// + public CreateAgreementError(){} /// /// Creates a new instance of . @@ -72,11 +84,11 @@ public class CreateAgreementError : BasicError public CreateAgreementError( string summary, string detail, - BasicError? institutionIdError, - BasicError? accessScopeError, - BasicError? maxHistoricalDaysError, - BasicError? accessValidForDaysError, - BasicError? agreementError + BasicResponse? institutionIdError, + BasicResponse? accessScopeError, + BasicResponse? maxHistoricalDaysError, + BasicResponse? accessValidForDaysError, + BasicResponse? agreementError ) : base(summary, detail) { InstitutionIdError = institutionIdError; diff --git a/src/RobinTTY.NordigenApiClient/Models/Errors/CreateRequisitionError.cs b/src/RobinTTY.NordigenApiClient/Models/Errors/CreateRequisitionError.cs index 25f8064..ddf611a 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Errors/CreateRequisitionError.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Errors/CreateRequisitionError.cs @@ -1,61 +1,75 @@ using System.Text.Json.Serialization; +using RobinTTY.NordigenApiClient.JsonConverters; using RobinTTY.NordigenApiClient.Models.Requests; +using RobinTTY.NordigenApiClient.Models.Responses; namespace RobinTTY.NordigenApiClient.Models.Errors; /// /// An error description as returned by the create operation of the requisitions endpoint of the Nordigen API. /// -public class CreateRequisitionError : BasicError +public class CreateRequisitionError : BasicResponse { /// /// An error that was returned related to the property sent during /// the request. /// [JsonPropertyName("reference")] - public BasicError? ReferenceError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? ReferenceError { get; } /// /// An error that was returned related to the property sent during /// the request. /// [JsonPropertyName("user_language")] - public BasicError? UserLanguageError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? UserLanguageError { get; } /// /// An error that was returned related to the property sent during /// the request. /// [JsonPropertyName("agreement")] - public BasicError? AgreementError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? AgreementError { get; } /// /// An error that was returned related to the property sent during the /// request. /// [JsonPropertyName("redirect")] - public BasicError? RedirectError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? RedirectError { get; } /// /// An error that was returned related to the property /// sent during the request. /// [JsonPropertyName("ssn")] - public BasicError? SocialSecurityNumberError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? SocialSecurityNumberError { get; } /// /// An error that was returned related to the property sent /// during the request. /// [JsonPropertyName("account_selection")] - public BasicError? AccountSelectionError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? AccountSelectionError { get; } /// /// An error that was returned related to the property sent /// during the request. /// [JsonPropertyName("institution_id")] - public BasicError? InstitutionIdError { get; } + [JsonConverter(typeof(StringArrayToBasicResponseConverter))] + public BasicResponse? InstitutionIdError { get; } + + /// + /// Creates a new instance of . + /// + public CreateRequisitionError(){} /// /// Creates a new instance of . @@ -91,10 +105,10 @@ public class CreateRequisitionError : BasicError /// property sent during the request. /// [JsonConstructor] - public CreateRequisitionError(string summary, string detail, BasicError? referenceError, - BasicError? userLanguageError, BasicError? agreementError, - BasicError? redirectError, BasicError? socialSecurityNumberError, BasicError? accountSelectionError, - BasicError? institutionIdError) : base(summary, detail) + public CreateRequisitionError(string summary, string detail, BasicResponse? referenceError, + BasicResponse? userLanguageError, BasicResponse? agreementError, + BasicResponse? redirectError, BasicResponse? socialSecurityNumberError, BasicResponse? accountSelectionError, + BasicResponse? institutionIdError) : base(summary, detail) { ReferenceError = referenceError; UserLanguageError = userLanguageError; diff --git a/src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsError.cs b/src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsError.cs deleted file mode 100644 index fb73ac6..0000000 --- a/src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsError.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json.Serialization; - -namespace RobinTTY.NordigenApiClient.Models.Errors; - -/// -/// An error description as returned by the institutions endpoint of the Nordigen API. -/// -public class InstitutionsError : BasicError -{ - /// - /// Creates a new instance of . - /// - /// The error related to the requested institutions. - [JsonConstructor] - public InstitutionsError(BasicError country) : base(country.Summary, country.Detail) - { - } -} - -/// -/// Representation of the institutions error as returned by the Nordigen API. -/// Since this representation doesn't add any useful information (only extra encapsulation) -/// it is transformed to align this error with other errors returned by the API. -/// -internal class InstitutionsErrorInternal -{ - [JsonPropertyName("country")] public BasicError? Country { get; } - - [JsonPropertyName("summary")] public string? Summary { get; } - - [JsonPropertyName("detail")] public string? Detail { get; } - - [JsonConstructor] - public InstitutionsErrorInternal(BasicError? country, string? summary, string? detail) - { - Country = country; - Summary = summary; - Detail = detail; - } -} diff --git a/src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsErrorInternal.cs b/src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsErrorInternal.cs new file mode 100644 index 0000000..234d3cf --- /dev/null +++ b/src/RobinTTY.NordigenApiClient/Models/Errors/InstitutionsErrorInternal.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using RobinTTY.NordigenApiClient.Models.Responses; + +namespace RobinTTY.NordigenApiClient.Models.Errors; + +/// +/// Representation of the institutions error as returned by the Nordigen API. +/// Since this representation doesn't add any useful information (only extra encapsulation) +/// it is transformed to align this error with other errors returned by the API. +/// +internal class InstitutionsErrorInternal : BasicResponse +{ + [JsonPropertyName("country")] public BasicResponse? Country { get; } + + /// + /// Creates a new instance of . + /// + public InstitutionsErrorInternal(){} + + /// + /// Creates a new instance of . + /// + /// The summary text of the response/error. + /// The detailed description of the response/error. + /// The error response returned for some requests. + [JsonConstructor] + public InstitutionsErrorInternal(string? summary, string? detail, BasicResponse? country) + { + Country = country; + Summary = country?.Summary ?? summary; + Detail = country?.Detail ?? detail; + } +} diff --git a/src/RobinTTY.NordigenApiClient/Models/Jwt/JsonWebTokenPair.cs b/src/RobinTTY.NordigenApiClient/Models/Jwt/JsonWebTokenPair.cs index 08898e0..1f7a7de 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Jwt/JsonWebTokenPair.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Jwt/JsonWebTokenPair.cs @@ -42,8 +42,8 @@ public class JsonWebTokenPair [JsonConstructor] public JsonWebTokenPair(JsonWebToken accessToken, JsonWebToken refreshToken, int accessExpires, int refreshExpires) { - AccessToken = accessToken; - RefreshToken = refreshToken; + AccessToken = accessToken ?? throw new ArgumentNullException(nameof(accessToken)); + RefreshToken = refreshToken ?? throw new ArgumentNullException(nameof(refreshToken)); AccessExpires = accessExpires; RefreshExpires = refreshExpires; } diff --git a/src/RobinTTY.NordigenApiClient/Models/NordigenClientCredentials.cs b/src/RobinTTY.NordigenApiClient/Models/NordigenClientCredentials.cs index 6924064..7fee811 100644 --- a/src/RobinTTY.NordigenApiClient/Models/NordigenClientCredentials.cs +++ b/src/RobinTTY.NordigenApiClient/Models/NordigenClientCredentials.cs @@ -26,7 +26,7 @@ public class NordigenClientCredentials /// Secret Nordigen key. public NordigenClientCredentials(string secretId, string secretKey) { - SecretId = secretId; - SecretKey = secretKey; + SecretId = secretId ?? throw new ArgumentNullException(nameof(secretId)); + SecretKey = secretKey ?? throw new ArgumentNullException(nameof(secretKey)); } } diff --git a/src/RobinTTY.NordigenApiClient/Models/Responses/BasicResponse.cs b/src/RobinTTY.NordigenApiClient/Models/Responses/BasicResponse.cs index 8c47917..9e2f2d0 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Responses/BasicResponse.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Responses/BasicResponse.cs @@ -1,33 +1,41 @@ using System.Text.Json.Serialization; +using RobinTTY.NordigenApiClient.JsonConverters; namespace RobinTTY.NordigenApiClient.Models.Responses; /// -/// A basic response returned by the Nordigen API containing a textual description of a result. +/// A basic response/error returned by the Nordigen API containing a textual description of the result. /// public class BasicResponse { /// - /// The summary text of the response + /// The summary text of the response/error. /// [JsonPropertyName("summary")] - public string? Summary { get; } + [JsonConverter(typeof(StringArrayMergeConverter))] + public string? Summary { get; init; } /// - /// The detailed description of the response. + /// The detailed description of the response/error. /// - [JsonPropertyName("details")] - public string? Details { get; } - + [JsonPropertyName("detail")] + [JsonConverter(typeof(StringArrayMergeConverter))] + public string? Detail { get; init; } + + /// + /// Creates a new instance of . + /// + public BasicResponse(){} + /// /// Creates a new instance of . /// - /// The summary text of the response. - /// The detailed description of the response. + /// The summary text of the response/error. + /// The detailed description of the response/error. [JsonConstructor] - public BasicResponse(string? summary, string? details) + public BasicResponse(string? summary, string? detail) { Summary = summary; - Details = details; + Detail = detail; } } diff --git a/src/RobinTTY.NordigenApiClient/Models/Responses/Institution.cs b/src/RobinTTY.NordigenApiClient/Models/Responses/Institution.cs index a9cbc64..3099d83 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Responses/Institution.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Responses/Institution.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using RobinTTY.NordigenApiClient.Endpoints; namespace RobinTTY.NordigenApiClient.Models.Responses; @@ -28,9 +29,9 @@ public class Institution /// /// The days for which the transaction history is available. /// - [JsonPropertyName("transaction_total_days")] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] - public int TransactionTotalDays { get; } + [JsonPropertyName("transaction_total_days")] + public uint TransactionTotalDays { get; } /// /// The countries the institution operates in. @@ -43,6 +44,24 @@ public class Institution /// [JsonPropertyName("logo")] public Uri Logo { get; } + + /// + /// Supported payment products for this institution. Only populated when calling . + /// + [JsonPropertyName("supported_payments")] + public SupportedPayments? SupportedPayments { get; } + + /// + /// Supported features for this institution. Only populated when calling . + /// + [JsonPropertyName("supported_features")] + public List? SupportedFeatures { get; } + + /// + /// Undocumented field returned by the GoCardless API. Only populated when calling . + /// + [JsonPropertyName("identification_codes")] + public List? IdentificationCodes { get; } /// /// Creates a new instance of . @@ -53,9 +72,12 @@ public class Institution /// The days for which the transaction history is available. /// The countries the institution operates in. /// A for the logo of the institution. + /// Supported payment products for this institution. + /// Supported features for this institution. + /// Undocumented field returned by the GoCardless API. [JsonConstructor] - public Institution(string id, string name, string bic, int transactionTotalDays, List countries, - Uri logo) + public Institution(string id, string name, string bic, uint transactionTotalDays, List countries, + Uri logo, SupportedPayments? supportedPayments, List? supportedFeatures, List? identificationCodes) { Id = id; Name = name; @@ -63,5 +85,29 @@ public Institution(string id, string name, string bic, int transactionTotalDays, TransactionTotalDays = transactionTotalDays; Countries = countries; Logo = logo; + SupportedPayments = supportedPayments; + SupportedFeatures = supportedFeatures; + IdentificationCodes = identificationCodes; + } +} + +/// +/// The payment products supported by an institution. +/// +public class SupportedPayments +{ + /// + /// Supported payment products in the single-payment category. + /// + [JsonPropertyName("single-payment")] + public List SinglePayment { get; } + + /// + /// Creates a new instance of + /// + /// Supported payment products in the single-payment category. + public SupportedPayments(List singlePayment) + { + SinglePayment = singlePayment; } } diff --git a/src/RobinTTY.NordigenApiClient/Models/Responses/PaymentProduct.cs b/src/RobinTTY.NordigenApiClient/Models/Responses/PaymentProduct.cs new file mode 100644 index 0000000..a915556 --- /dev/null +++ b/src/RobinTTY.NordigenApiClient/Models/Responses/PaymentProduct.cs @@ -0,0 +1,89 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; +using RobinTTY.NordigenApiClient.JsonConverters; + +namespace RobinTTY.NordigenApiClient.Models.Responses; + +/// +/// A payment product offered by an institution. +/// +[JsonConverter(typeof(EnumDescriptionConverter))] +public enum PaymentProduct +{ + /// + /// An undefined payment product type. Assigned if the type couldn't be matched to any known types. + /// + Undefined, + + /// + /// TARGET2 is the real-time gross settlement (RTGS) system owned and operated by the Eurosystem. + /// + [Description("T2P")] + Target2Payments, + + /// + /// SEPA Credit Transfer, more commonly abbreviated as SCT, is a payment processing scheme used for making one-time, euro-denominated fund transfers between banks and payment service providers (PSPs) in the SEPA area. + /// + [Description("SCT")] + SepaCreditTransfers, + + /// + /// Instant SEPA Credit Transfer supports money transfers of up to €100,000 between participating banks or PSPs in the SEPA area in real or near-real time. + /// + [Description("ISCT")] + InstantSepaCreditTransfer, + + /// + /// Cross-border payments are transactions sent from one country and received in a different country. + /// + [Description("CBCT")] + CrossBorderCreditTransfers, + + /// + /// Bacs Payment Schemes Limited (Bacs), previously known as Bankers' Automated Clearing System, is responsible for the clearing and settlement of UK automated direct debit and Bacs Direct Credit and the provision of third-party services. + /// + [Description("BACS")] + BacsPaymentSchemesLimited, + + /// + /// The Clearing House Automated Payment System (CHAPS) is a real-time gross settlement payment system used for sterling transactions in the United Kingdom. + /// + [Description("CHAPS")] + ClearingHouseAutomatedPaymentSystem, + + /// + /// The Faster Payments Service (FPS) is a United Kingdom banking initiative to reduce payment times between different banks' customer accounts to typically a few seconds, from the three working days that transfers usually take using the long-established BACS system. + /// + [Description("FPS")] + FasterPaymentScheme, + + /// + /// SWIFT payments are international electronic transactions facilitated by an intermediary bank. + /// + [Description("SWIFT")] + SwiftPaymentService, + + /// + /// A balance transfer is the transfer of the balance in an account to another account, often held at another institution. It is most commonly used when describing a credit card balance transfer. + /// + [Description("BT")] + BalanceTransfer, + + /// + /// Money transfer generally refers to one of several cashless modes of payment or payment systems. + /// + [Description("MT")] + MoneyTransfer, + + /// + /// A domestic transfer is a transfer completed between accounts that are held in the same country. + /// + [Description("DCT")] + DomesticCreditTransfer, + + /// + /// An instant domestic transfer is a transfer completed between accounts that are held in the same country in real-time. + /// + [Description("IDCT")] + InstantDomesticCreditTransfer +} diff --git a/src/RobinTTY.NordigenApiClient/Models/Responses/ResponsePage.cs b/src/RobinTTY.NordigenApiClient/Models/Responses/ResponsePage.cs index 1058e3c..e3a38ff 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Responses/ResponsePage.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Responses/ResponsePage.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using RobinTTY.NordigenApiClient.Models.Errors; namespace RobinTTY.NordigenApiClient.Models.Responses; @@ -31,7 +30,7 @@ public class ResponsePage /// The results that were fetched with this page. /// [JsonPropertyName("results")] - public IEnumerable Results { get; } + public List Results { get; } /// /// Creates a new instance of . @@ -40,7 +39,7 @@ public class ResponsePage /// The URI of the next response page. /// The URI of the last response page. /// The results that were fetched with this page. - public ResponsePage(uint count, Uri? next, Uri? previous, IEnumerable results) + public ResponsePage(uint count, Uri? next, Uri? previous, List results) { Count = count; Next = next; @@ -57,11 +56,11 @@ public ResponsePage(uint count, Uri? next, Uri? previous, IEnumerable results /// Either a containing the next /// or null if there is no next page to retrieve. /// - public async Task, BasicError>?> GetNextPage(NordigenClient nordigenClient, + public async Task, BasicResponse>?> GetNextPage(NordigenClient nordigenClient, CancellationToken cancellationToken = default) { - if (Next == null) return null; - return await nordigenClient.MakeRequest, BasicError>(Next.AbsoluteUri, HttpMethod.Get, + if (Next is null) return null; + return await nordigenClient.MakeRequest, BasicResponse>(Next.AbsoluteUri, HttpMethod.Get, cancellationToken); } @@ -74,11 +73,11 @@ public ResponsePage(uint count, Uri? next, Uri? previous, IEnumerable results /// Either a containing the previous /// or null if there is no previous page to retrieve. /// - public async Task, BasicError>?> GetPreviousPage(NordigenClient nordigenClient, + public async Task, BasicResponse>?> GetPreviousPage(NordigenClient nordigenClient, CancellationToken cancellationToken = default) { - if (Previous == null) return null; - return await nordigenClient.MakeRequest, BasicError>(Previous.AbsoluteUri, HttpMethod.Get, + if (Previous is null) return null; + return await nordigenClient.MakeRequest, BasicResponse>(Previous.AbsoluteUri, HttpMethod.Get, cancellationToken); } } diff --git a/src/RobinTTY.NordigenApiClient/Models/Responses/Transaction.cs b/src/RobinTTY.NordigenApiClient/Models/Responses/Transaction.cs index 42d1930..236d655 100644 --- a/src/RobinTTY.NordigenApiClient/Models/Responses/Transaction.cs +++ b/src/RobinTTY.NordigenApiClient/Models/Responses/Transaction.cs @@ -139,7 +139,7 @@ public class Transaction /// /// [JsonPropertyName("remittanceInformationUnstructuredArray")] - public IEnumerable? RemittanceInformationUnstructuredArray { get; } + public List? RemittanceInformationUnstructuredArray { get; } /// /// Reference issued by the seller used to establish a link between the payment of an invoice and the invoice instance. @@ -157,7 +157,7 @@ public class Transaction /// purchase order number. /// [JsonPropertyName("remittanceInformationStructuredArray")] - public IEnumerable? RemittanceInformationStructuredArray { get; } + public List? RemittanceInformationStructuredArray { get; } /// /// Unique identification assigned by the initiating party to unambiguously identify the transaction. This @@ -219,8 +219,8 @@ public class Transaction /// Array of the report exchange rate. /// [JsonPropertyName("currencyExchange")] - [JsonConverter(typeof(SingleOrArrayConverter, CurrencyExchange>))] - public IEnumerable? CurrencyExchange { get; } + [JsonConverter(typeof(SingleOrArrayConverter, CurrencyExchange>))] + public List? CurrencyExchange { get; } /// /// The identification of the transaction as used for reference by the financial institution. @@ -339,13 +339,13 @@ public class Transaction public Transaction(string? transactionId, string? debtorName, MinimalBankAccount? debtorAccount, string? ultimateDebtor, string? creditorName, MinimalBankAccount? creditorAccount, AmountCurrencyPair transactionAmount, string? bankTransactionCode, DateTime? bookingDate, DateTime? valueDate, - string? remittanceInformationUnstructured, IEnumerable? remittanceInformationUnstructuredArray, + string? remittanceInformationUnstructured, List? remittanceInformationUnstructuredArray, string? endToEndId, string? mandateId, string? proprietaryBankTransactionCode, string? purposeCode, string? debtorAgent, string? creditorAgent, string? ultimateCreditor, string? creditorId, DateTime? valueDateTime, string? remittanceInformationStructured, - IEnumerable? remittanceInformationStructuredArray, string? additionalInformation, + List? remittanceInformationStructuredArray, string? additionalInformation, string? additionalInformationStructured, Balance? balanceAfterTransaction, string? checkId, - IEnumerable? currencyExchange, string? entryReference, string? internalTransactionId, + List? currencyExchange, string? entryReference, string? internalTransactionId, string? merchantCategoryCode, DateTime? bookingDateTime) { TransactionId = transactionId; diff --git a/src/RobinTTY.NordigenApiClient/NordigenClient.cs b/src/RobinTTY.NordigenApiClient/NordigenClient.cs index 54cd3aa..b054697 100644 --- a/src/RobinTTY.NordigenApiClient/NordigenClient.cs +++ b/src/RobinTTY.NordigenApiClient/NordigenClient.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Net; +using System.Text.Json; using RobinTTY.NordigenApiClient.Contracts; using RobinTTY.NordigenApiClient.Endpoints; using RobinTTY.NordigenApiClient.JsonConverters; @@ -34,7 +35,7 @@ public class NordigenClient : INordigenClient /// public IAccountsEndpoint AccountsEndpoint { get; } - + /// public event EventHandler? TokenPairUpdated; @@ -53,12 +54,10 @@ public NordigenClient(HttpClient httpClient, NordigenClientCredentials credentia Converters = { new JsonWebTokenConverter(), new GuidConverter(), - new CultureSpecificDecimalConverter(), new InstitutionsErrorConverter() + new CultureSpecificDecimalConverter() } }; - - if (_httpClient.BaseAddress == null) - _httpClient.BaseAddress = new Uri(NordigenEndpointUrls.Base); + _httpClient.BaseAddress ??= new Uri(NordigenEndpointUrls.Base); Credentials = credentials; JsonWebTokenPair = jsonWebTokenPair; @@ -69,6 +68,18 @@ public NordigenClient(HttpClient httpClient, NordigenClientCredentials credentia AccountsEndpoint = new AccountsEndpoint(this); } + /// + /// Carries out the request to the GoCardless API, gathering a valid JWT if necessary. + /// + /// The URI of the API endpoint. + /// The to use for this request. + /// Token to signal cancellation of the operation. + /// Optional query parameters to add to the request. + /// Optional body to add to the request. + /// Whether to use authentication. + /// The type of the response. + /// The type of the error. + /// The response to the request. internal async Task> MakeRequest( string uri, HttpMethod method, @@ -76,16 +87,28 @@ internal async Task> MakeRequest>? query = null, HttpContent? body = null, bool useAuthentication = true - ) where TResponse : class where TError : class + ) where TResponse : class where TError : BasicResponse, new() { var requestUri = query != null ? uri + UriQueryBuilder.GetQueryString(query) : uri; HttpClient client; + + // When an endpoint that requires authentication is called the client tries to update the JWT first + // - The updating is done using a semaphore to avoid multiple threads trying to update the token simultaneously + // - If the request to get the token succeeds, the subsequent request is executed + // - If the request to get the token fails, the error response from the token endpoint is returned instead if (useAuthentication) { await TokenSemaphore.WaitAsync(cancellationToken); + try { - JsonWebTokenPair = await TryGetValidTokenPair(cancellationToken); + var tokenResponse = await TryGetValidTokenPair(cancellationToken); + + if (tokenResponse.IsSuccess) + JsonWebTokenPair = tokenResponse.Result; + else + return new NordigenApiResponse(tokenResponse.StatusCode, tokenResponse.IsSuccess, + null, new TError {Summary = tokenResponse.Error.Summary, Detail = tokenResponse.Error.Detail}); } finally { @@ -99,38 +122,47 @@ internal async Task> MakeRequest.FromHttpResponse(response, _serializerOptions, cancellationToken); } + private static async Task ExecuteRequest(HttpClient client, HttpMethod method, + string requestUri, + CancellationToken cancellationToken, HttpContent? body = null) + { + if (method == HttpMethod.Get) + return await client.GetAsync(requestUri, cancellationToken); + if (method == HttpMethod.Post) + return await client.PostAsync(requestUri, body, cancellationToken); + if (method == HttpMethod.Delete) + return await client.DeleteAsync(requestUri, cancellationToken); + if (method == HttpMethod.Put) + return await client.PutAsync(requestUri, body, cancellationToken); + + throw new NotImplementedException(); + } + /// /// Tries to retrieve a valid . /// /// An optional token to signal cancellation of the operation. /// - /// A valid if the operation was successful. - /// Otherwise returns null. + /// A containing the or + /// the error of the operation. /// - private async Task TryGetValidTokenPair(CancellationToken cancellationToken = default) + private async Task> TryGetValidTokenPair( + CancellationToken cancellationToken = default) { // Request a new token if it is null or if the refresh token has expired if (JsonWebTokenPair == null || JsonWebTokenPair.RefreshToken.IsExpired(TimeSpan.FromMinutes(1))) { var response = await TokenEndpoint.GetTokenPair(cancellationToken); - TokenPairUpdated?.Invoke(this, new TokenPairUpdatedEventArgs(response.Result)); - return response.Result; + if (response.IsSuccess) + TokenPairUpdated?.Invoke(this, new TokenPairUpdatedEventArgs(response.Result)); + + return response; } // Refresh the current access token if it's expired (or valid for less than a minute) @@ -139,15 +171,21 @@ internal async Task> MakeRequest(response.StatusCode, + response.IsSuccess, token, response.Error); + + if (token is not null) + TokenPairUpdated?.Invoke(this, new TokenPairUpdatedEventArgs(token)); + + return tokenPairResponse; } - // Token pair is still valid and can be returned - return JsonWebTokenPair; + // Token pair is still valid and can be returned - wrap in NordigenApiResponse + return new NordigenApiResponse(HttpStatusCode.OK, true, JsonWebTokenPair, + null); } } @@ -159,13 +197,13 @@ public class TokenPairUpdatedEventArgs : EventArgs /// /// The updated . /// - public JsonWebTokenPair? JsonWebTokenPair { get; set; } + public JsonWebTokenPair JsonWebTokenPair { get; set; } /// /// Creates a new instance of . /// /// The updated . - public TokenPairUpdatedEventArgs(JsonWebTokenPair? jsonWebTokenPair) + public TokenPairUpdatedEventArgs(JsonWebTokenPair jsonWebTokenPair) { JsonWebTokenPair = jsonWebTokenPair; } diff --git a/src/RobinTTY.NordigenApiClient/RobinTTY.NordigenApiClient.csproj b/src/RobinTTY.NordigenApiClient/RobinTTY.NordigenApiClient.csproj index 574ea79..38eb2ca 100644 --- a/src/RobinTTY.NordigenApiClient/RobinTTY.NordigenApiClient.csproj +++ b/src/RobinTTY.NordigenApiClient/RobinTTY.NordigenApiClient.csproj @@ -16,7 +16,7 @@ Nordigen; API; client $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/release-notes.txt")) MIT - 7.1.0 + 8.0.0 true snupkg @@ -29,7 +29,7 @@ - + diff --git a/src/RobinTTY.NordigenApiClient/Utility/HttpClientExtensions.cs b/src/RobinTTY.NordigenApiClient/Utility/HttpClientExtensions.cs index 380e775..365cff9 100644 --- a/src/RobinTTY.NordigenApiClient/Utility/HttpClientExtensions.cs +++ b/src/RobinTTY.NordigenApiClient/Utility/HttpClientExtensions.cs @@ -17,7 +17,7 @@ internal static class HttpClientExtensions /// internal static HttpClient UseNordigenAuthenticationHeader(this HttpClient client, JsonWebTokenPair? tokenPair) { - if (tokenPair == null) return client; + if (tokenPair is null) return client; var rawToken = tokenPair.AccessToken.EncodedToken; client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", rawToken); return client; diff --git a/src/RobinTTY.NordigenApiClient/Utility/UriQueryBuilder.cs b/src/RobinTTY.NordigenApiClient/Utility/UriQueryBuilder.cs index 0c5f09d..c69e50c 100644 --- a/src/RobinTTY.NordigenApiClient/Utility/UriQueryBuilder.cs +++ b/src/RobinTTY.NordigenApiClient/Utility/UriQueryBuilder.cs @@ -16,6 +16,6 @@ internal static string GetQueryString(IEnumerable> { var query = HttpUtility.ParseQueryString(string.Empty); foreach (var kvp in queryKeyValuePairs) query.Add(kvp.Key, kvp.Value); - return query.ToString() == null ? string.Empty : $"?{query}"; + return query.ToString() is null ? string.Empty : $"?{query}"; } } diff --git a/src/RobinTTY.NordigenApiClient/release-notes.txt b/src/RobinTTY.NordigenApiClient/release-notes.txt index 866d24d..2200916 100644 --- a/src/RobinTTY.NordigenApiClient/release-notes.txt +++ b/src/RobinTTY.NordigenApiClient/release-notes.txt @@ -1 +1,2 @@ -- Added ability to configure the base API URL through the HttpClient +This version improves many aspects of the library. Since this version contains breaking changes please check the release notes before updating. +For the full release notes please see: https://github.com/RobinTTY/NordigenApiClient/releases/tag/v8.0.0 \ No newline at end of file