From c9e3461440358cda0100c253550339801b46394c Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:03:40 +0100 Subject: [PATCH 01/17] CONFIG: Enable Internals Visibility For Sdk Unit And Integration Test Projects --- NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj b/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj index a15382b..8ab16f3 100644 --- a/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj +++ b/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj @@ -50,8 +50,9 @@ - + + From f8c43c2b41fa5cd4e4d404f71cfc154b1b8750d8 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:03:41 +0100 Subject: [PATCH 02/17] SdkUnitTests -> FAIL --- .../Brokers/Storages/FakeSession.cs | 32 ++ .../SessionApiPlatformStateBrokerTests.cs | 102 +++++++ .../SessionApiPlatformTokenBrokerTests.cs | 185 ++++++++++++ .../ServiceCollectionExtensionsTests.cs | 78 +++++ ...dentityServiceClientTests.Cancellations.cs | 86 ++++++ ...reIdentityServiceClientTests.Exceptions.cs | 162 ++++++++++ .../CareIdentityServiceClientTests.Logic.cs | 83 +++++ .../CareIdentityServiceClientTests.cs | 92 ++++++ ...lientTests.Cancellations.SearchPatients.cs | 75 +++++ ...ceClientTests.Exceptions.SearchPatients.cs | 174 +++++++++++ ...ServiceClientTests.Logic.SearchPatients.cs | 39 +++ .../PersonalDemographicsServiceClientTests.cs | 87 ++++++ .../CareIdentityServiceTests.Cancellations.cs | 129 ++++++++ .../CareIdentityServiceTests.Exceptions.cs | 284 ++++++++++++++++++ ...dentityServiceTests.Logic.BuildLoginUrl.cs | 77 +++++ ...CareIdentityServiceTests.Logic.Callback.cs | 198 ++++++++++++ ...entityServiceTests.Logic.GetAccessToken.cs | 165 ++++++++++ ...eIdentityServiceTests.Logic.GetUserInfo.cs | 75 +++++ .../CareIdentityServiceTests.Logic.Logout.cs | 66 ++++ .../CareIdentityServiceTests.Validations.cs | 186 ++++++++++++ .../CareIdentityServiceTests.cs | 141 +++++++++ ...rviceTests.Cancellations.SearchPatients.cs | 122 ++++++++ ...sServiceTests.Exceptions.SearchPatients.cs | 116 +++++++ .../PdsServiceTests.Logic.SearchPatients.cs | 193 ++++++++++++ ...ServiceTests.Validations.SearchPatients.cs | 122 ++++++++ .../Foundations/Pds/PdsServiceTests.cs | 124 ++++++++ ...rviceTests.Cancellations.SearchPatients.cs | 88 ++++++ ...nServiceTests.Exceptions.SearchPatients.cs | 146 +++++++++ ...rationServiceTests.Logic.SearchPatients.cs | 77 +++++ ...ServiceTests.Validations.SearchPatients.cs | 120 ++++++++ .../Pds/PdsOrchestrationServiceTests.cs | 92 ++++++ ...iceProcessingServiceTests.Cancellations.cs | 146 +++++++++ ...erviceProcessingServiceTests.Exceptions.cs | 181 +++++++++++ ...tityServiceProcessingServiceTests.Logic.cs | 117 ++++++++ ...rviceProcessingServiceTests.Validations.cs | 132 ++++++++ ...reIdentityServiceProcessingServiceTests.cs | 96 ++++++ 36 files changed, 4388 insertions(+) create mode 100644 NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/FakeSession.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformStateBrokerTests.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/ServiceCollectionExtensionsTests.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Logic.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Logic.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.BuildLoginUrl.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Callback.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetUserInfo.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Logout.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Logic.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Logic.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Logic.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/FakeSession.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/FakeSession.cs new file mode 100644 index 0000000..3475d5c --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/FakeSession.cs @@ -0,0 +1,32 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit.Brokers.Storages +{ + internal sealed class FakeSession : ISession + { + private readonly Dictionary store = new Dictionary(); + + public bool IsAvailable => true; + public string Id => "fake-session"; + public IEnumerable Keys => this.store.Keys; + + public void Clear() => this.store.Clear(); + + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public void Remove(string key) => this.store.Remove(key); + + public void Set(string key, byte[] value) => this.store[key] = value; + + public bool TryGetValue(string key, out byte[] value) => this.store.TryGetValue(key, out value); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformStateBrokerTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformStateBrokerTests.cs new file mode 100644 index 0000000..e35843c --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformStateBrokerTests.cs @@ -0,0 +1,102 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; +using NHSDigital.ApiPlatform.Sdk.AspNetCore.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using Tynamix.ObjectFiller; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit.Brokers.Storages +{ + public class SessionApiPlatformStateBrokerTests + { + private readonly Mock httpContextAccessorMock; + private readonly FakeSession fakeSession; + private readonly IApiPlatformStateBroker apiPlatformStateBroker; + + public SessionApiPlatformStateBrokerTests() + { + this.httpContextAccessorMock = new Mock(); + this.fakeSession = new FakeSession(); + + var httpContext = new DefaultHttpContext + { + Session = this.fakeSession + }; + + this.httpContextAccessorMock.Setup(accessor => + accessor.HttpContext) + .Returns(httpContext); + + this.apiPlatformStateBroker = + new SessionApiPlatformStateBroker(this.httpContextAccessorMock.Object); + } + + [Fact] + public async Task ShouldStoreCsrfStateAsync() + { + // given + string randomState = GetRandomString(); + + // when + await this.apiPlatformStateBroker.StoreCsrfStateAsync(randomState); + + // then + string actualState = await this.apiPlatformStateBroker.GetCsrfStateAsync(); + actualState.Should().Be(randomState); + } + + [Fact] + public async Task ShouldReturnNullOnGetCsrfStateIfStateWasNeverStoredAsync() + { + // given + // when + string actualState = await this.apiPlatformStateBroker.GetCsrfStateAsync(); + + // then + actualState.Should().BeNull(); + } + + [Fact] + public async Task ShouldClearCsrfStateAsync() + { + // given + await this.apiPlatformStateBroker.StoreCsrfStateAsync(GetRandomString()); + + // when + await this.apiPlatformStateBroker.ClearCsrfStateAsync(); + + // then + string actualState = await this.apiPlatformStateBroker.GetCsrfStateAsync(); + actualState.Should().BeNull(); + } + + [Fact] + public async Task ShouldThrowInvalidOperationExceptionOnStoreCsrfStateIfHttpContextIsMissingAsync() + { + // given + var httpContextAccessorMock = new Mock(); + + httpContextAccessorMock.Setup(accessor => + accessor.HttpContext) + .Returns((HttpContext)null); + + IApiPlatformStateBroker stateBroker = + new SessionApiPlatformStateBroker(httpContextAccessorMock.Object); + + // when + // then + await Assert.ThrowsAsync(async () => + await stateBroker.StoreCsrfStateAsync(GetRandomString())); + } + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs new file mode 100644 index 0000000..09b07ae --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs @@ -0,0 +1,185 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; +using NHSDigital.ApiPlatform.Sdk.AspNetCore.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using Tynamix.ObjectFiller; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit.Brokers.Storages +{ + public class SessionApiPlatformTokenBrokerTests + { + private readonly Mock httpContextAccessorMock; + private readonly IApiPlatformTokenBroker apiPlatformTokenBroker; + + public SessionApiPlatformTokenBrokerTests() + { + this.httpContextAccessorMock = new Mock(); + + var httpContext = new DefaultHttpContext + { + Session = new FakeSession() + }; + + this.httpContextAccessorMock.Setup(accessor => + accessor.HttpContext) + .Returns(httpContext); + + this.apiPlatformTokenBroker = + new SessionApiPlatformTokenBroker(this.httpContextAccessorMock.Object); + } + + [Fact] + public async Task ShouldStoreAccessTokenAsync() + { + // given + string randomAccessToken = GetRandomString(); + DateTimeOffset randomExpiresAtUtc = GetRandomDateTimeOffset(); + + // when + await this.apiPlatformTokenBroker.StoreAccessTokenAsync(randomAccessToken, randomExpiresAtUtc); + + // then + var (actualToken, _) = await this.apiPlatformTokenBroker.GetAccessTokenAsync(); + actualToken.Should().Be(randomAccessToken); + } + + [Fact] + public async Task ShouldStoreAccessTokenExpiryToTheSecondAsync() + { + // given + string randomAccessToken = GetRandomString(); + DateTimeOffset randomExpiresAtUtc = GetRandomDateTimeOffset(); + + DateTimeOffset expectedExpiresAtUtc = + DateTimeOffset.FromUnixTimeSeconds(randomExpiresAtUtc.ToUnixTimeSeconds()); + + // when + await this.apiPlatformTokenBroker.StoreAccessTokenAsync(randomAccessToken, randomExpiresAtUtc); + + // then + var (_, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetAccessTokenAsync(); + actualExpiresAtUtc.Should().Be(expectedExpiresAtUtc); + } + + [Fact] + public async Task ShouldReturnNullsOnGetAccessTokenIfTokenWasNeverStoredAsync() + { + // given + // when + var (actualToken, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetAccessTokenAsync(); + + // then + actualToken.Should().BeNull(); + actualExpiresAtUtc.Should().BeNull(); + } + + [Fact] + public async Task ShouldClearAccessTokenAsync() + { + // given + await this.apiPlatformTokenBroker.StoreAccessTokenAsync( + GetRandomString(), + GetRandomDateTimeOffset()); + + // when + await this.apiPlatformTokenBroker.ClearAccessTokenAsync(); + + // then + var (actualToken, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetAccessTokenAsync(); + actualToken.Should().BeNull(); + actualExpiresAtUtc.Should().BeNull(); + } + + [Fact] + public async Task ShouldStoreRefreshTokenAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomExpiresAtUtc = GetRandomDateTimeOffset(); + + // when + await this.apiPlatformTokenBroker.StoreRefreshTokenAsync(randomRefreshToken, randomExpiresAtUtc); + + // then + var (actualToken, _) = await this.apiPlatformTokenBroker.GetRefreshTokenAsync(); + actualToken.Should().Be(randomRefreshToken); + } + + [Fact] + public async Task ShouldClearRefreshTokenAsync() + { + // given + await this.apiPlatformTokenBroker.StoreRefreshTokenAsync( + GetRandomString(), + GetRandomDateTimeOffset()); + + // when + await this.apiPlatformTokenBroker.ClearRefreshTokenAsync(); + + // then + var (actualToken, _) = await this.apiPlatformTokenBroker.GetRefreshTokenAsync(); + actualToken.Should().BeNull(); + } + + [Fact] + public async Task ShouldStoreActiveRoleAsync() + { + // given + string randomRoleId = GetRandomString(); + + // when + await this.apiPlatformTokenBroker.StoreActiveRoleAsync(randomRoleId); + + // then + string actualRoleId = await this.apiPlatformTokenBroker.GetActiveRoleAsync(); + actualRoleId.Should().Be(randomRoleId); + } + + [Fact] + public async Task ShouldClearActiveRoleAsync() + { + // given + await this.apiPlatformTokenBroker.StoreActiveRoleAsync(GetRandomString()); + + // when + await this.apiPlatformTokenBroker.ClearActiveRoleAsync(); + + // then + string actualRoleId = await this.apiPlatformTokenBroker.GetActiveRoleAsync(); + actualRoleId.Should().BeNull(); + } + + [Fact] + public async Task ShouldThrowInvalidOperationExceptionOnGetAccessTokenIfHttpContextIsMissingAsync() + { + // given + var httpContextAccessorMock = new Mock(); + + httpContextAccessorMock.Setup(accessor => + accessor.HttpContext) + .Returns((HttpContext)null); + + IApiPlatformTokenBroker tokenBroker = + new SessionApiPlatformTokenBroker(httpContextAccessorMock.Object); + + // when + // then + await Assert.ThrowsAsync(async () => + await tokenBroker.GetAccessTokenAsync()); + } + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static DateTimeOffset GetRandomDateTimeOffset() => + new DateTimeRange(earliestDate: new DateTime(2020, 1, 1)).GetValue(); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/ServiceCollectionExtensionsTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..b4ff793 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,78 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Linq; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NHSDigital.ApiPlatform.Sdk.AspNetCore.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit +{ + public class ServiceCollectionExtensionsTests + { + [Fact] + public void ShouldRegisterHttpContextAccessorOnAddApiPlatformSdkAspNetCore() + { + // given + IServiceCollection services = new ServiceCollection(); + + // when + services.AddApiPlatformSdkAspNetCore(); + + // then + services.Should().Contain(descriptor => + descriptor.ServiceType == typeof(IHttpContextAccessor)); + } + + [Fact] + public void ShouldRegisterSessionStateBrokerOnAddApiPlatformSdkAspNetCore() + { + // given + IServiceCollection services = new ServiceCollection(); + + // when + services.AddApiPlatformSdkAspNetCore(); + + // then + ServiceDescriptor actualDescriptor = services.Single(descriptor => + descriptor.ServiceType == typeof(IApiPlatformStateBroker)); + + actualDescriptor.ImplementationType.Should().Be(typeof(SessionApiPlatformStateBroker)); + actualDescriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); + } + + [Fact] + public void ShouldRegisterSessionTokenBrokerOnAddApiPlatformSdkAspNetCore() + { + // given + IServiceCollection services = new ServiceCollection(); + + // when + services.AddApiPlatformSdkAspNetCore(); + + // then + ServiceDescriptor actualDescriptor = services.Single(descriptor => + descriptor.ServiceType == typeof(IApiPlatformTokenBroker)); + + actualDescriptor.ImplementationType.Should().Be(typeof(SessionApiPlatformTokenBroker)); + actualDescriptor.Lifetime.Should().Be(ServiceLifetime.Scoped); + } + + [Fact] + public void ShouldReturnSameServiceCollectionOnAddApiPlatformSdkAspNetCore() + { + // given + IServiceCollection services = new ServiceCollection(); + + // when + IServiceCollection actualServices = services.AddApiPlatformSdkAspNetCore(); + + // then + actualServices.Should().BeSameAs(services); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs new file mode 100644 index 0000000..666f8b1 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs @@ -0,0 +1,86 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionOnBuildLoginUrlAsync() + { + // given + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + // when + ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); + + // then + await Assert.ThrowsAsync(async () => await buildLoginUrlTask); + } + + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionOnLogoutAsync() + { + // given + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + // when + ValueTask logoutTask = this.careIdentityServiceClient.LogoutAsync(); + + // then + await Assert.ThrowsAsync(async () => await logoutTask); + } + + [Fact] + public async Task ShouldNotWrapTaskCanceledExceptionOnGetAccessTokenAsync() + { + // given + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(new TaskCanceledException()); + + // when + ValueTask getAccessTokenTask = this.careIdentityServiceClient.GetAccessTokenAsync(); + + // then + await Assert.ThrowsAnyAsync(async () => await getAccessTokenTask); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToProcessingServiceOnGetUserInfoAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetUserInfoAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(CreateRandomNhsUserInfo()); + + // when + await this.careIdentityServiceClient.GetUserInfoAsync(randomCode, randomState, cancellationToken); + + // then + this.careIdentityServiceProcessingServiceMock.Verify(service => + service.GetUserInfoAsync(randomCode, randomState, cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs new file mode 100644 index 0000000..fc142a7 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs @@ -0,0 +1,162 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + [Theory] + [MemberData(nameof(ValidationExceptions))] + public async Task ShouldThrowClientValidationExceptionOnBuildLoginUrlIfValidationErrorOccursAsync( + Xeption validationException) + { + // given + var expectedException = + new CareIdentityServiceClientValidationException( + message: "Care identity service client validation error occurred, fix errors and try again.", + innerException: validationException.InnerException as Xeption); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(validationException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); + + CareIdentityServiceClientValidationException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(DependencyValidationExceptions))] + public async Task ShouldThrowClientDependencyValidationExceptionOnLogoutIfDependencyValidationErrorOccursAsync( + Xeption dependencyValidationException) + { + // given + var expectedException = + new CareIdentityServiceClientDependencyValidationException( + message: "Care identity service client validation error occurred, fix errors and try again.", + innerException: dependencyValidationException.InnerException as Xeption); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(dependencyValidationException); + + // when + ValueTask logoutTask = this.careIdentityServiceClient.LogoutAsync(); + + CareIdentityServiceClientDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowClientDependencyExceptionOnGetAccessTokenIfDependencyErrorOccursAsync( + Xeption dependencyException) + { + // given + var expectedException = + new CareIdentityServiceClientDependencyException( + message: "Care identity service client dependency error occurred, contact support.", + innerException: dependencyException.InnerException as Xeption); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(dependencyException); + + // when + ValueTask getAccessTokenTask = this.careIdentityServiceClient.GetAccessTokenAsync(); + + CareIdentityServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await getAccessTokenTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowClientServiceExceptionOnGetUserInfoIfServiceErrorOccursAsync( + Xeption serviceException) + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + + var expectedException = + new CareIdentityServiceClientServiceException( + message: "Care identity service client service error occurred, contact support.", + innerException: serviceException.InnerException as Xeption); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetUserInfoAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(serviceException); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceClient.GetUserInfoAsync(randomCode, randomState); + + CareIdentityServiceClientServiceException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(UnexpectedExceptions))] + public async Task ShouldThrowClientServiceExceptionOnBuildLoginUrlIfUnexpectedErrorOccursAsync( + Exception unexpectedException) + { + // given + var failedCareIdentityServiceClientException = + new FailedCareIdentityServiceClientException( + message: "Unexpected error occurred, contact support.", + innerException: unexpectedException, + data: unexpectedException.Data); + + var expectedException = + new CareIdentityServiceClientServiceException( + message: "Care identity service client service error occurred, contact support.", + innerException: failedCareIdentityServiceClientException); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(unexpectedException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); + + CareIdentityServiceClientServiceException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Logic.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Logic.cs new file mode 100644 index 0000000..39fada1 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Logic.cs @@ -0,0 +1,83 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + [Fact] + public async Task ShouldBuildLoginUrlAsync() + { + // given + string randomLoginUrl = GetRandomString(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ReturnsAsync(randomLoginUrl); + + // when + string actualLoginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync(); + + // then + actualLoginUrl.Should().Be(randomLoginUrl); + } + + [Fact] + public async Task ShouldLogoutAsync() + { + // given + // when + await this.careIdentityServiceClient.LogoutAsync(); + + // then + this.careIdentityServiceProcessingServiceMock.Verify(service => + service.LogoutAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldGetAccessTokenAsync() + { + // given + string randomAccessToken = GetRandomString(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + // when + string actualAccessToken = await this.careIdentityServiceClient.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomAccessToken); + } + + [Fact] + public async Task ShouldGetUserInfoAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetUserInfoAsync(randomCode, randomState, It.IsAny())) + .ReturnsAsync(randomUserInfo); + + // when + NhsUserInfo actualUserInfo = + await this.careIdentityServiceClient.GetUserInfoAsync(randomCode, randomState); + + // then + actualUserInfo.Should().BeSameAs(randomUserInfo); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.cs new file mode 100644 index 0000000..b970659 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.cs @@ -0,0 +1,92 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Clients.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Services.Processings.CareIdentityServices; +using Tynamix.ObjectFiller; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + private readonly Mock careIdentityServiceProcessingServiceMock; + private readonly ICareIdentityServiceClient careIdentityServiceClient; + + public CareIdentityServiceClientTests() + { + this.careIdentityServiceProcessingServiceMock = + new Mock(); + + this.careIdentityServiceClient = + new CareIdentityServiceClient(this.careIdentityServiceProcessingServiceMock.Object); + } + + public static TheoryData ValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceProcessingValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceProcessingDependencyValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceProcessingDependencyException(GetRandomString(), innerException) + }; + } + + public static TheoryData ServiceExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceProcessingServiceException(GetRandomString(), innerException) + }; + } + + public static TheoryData UnexpectedExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new TimeoutException() + }; + + private static NhsUserInfo CreateRandomNhsUserInfo() => + new NhsUserInfo + { + NhsIdUserUid = GetRandomString(), + Name = GetRandomString(), + Sub = GetRandomString(), + NhsIdNrbacRoles = new List() + }; + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs new file mode 100644 index 0000000..775c16b --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs @@ -0,0 +1,75 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServices +{ + public partial class PersonalDemographicsServiceClientTests + { + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + // then + await Assert.ThrowsAsync(async () => await searchPatientsTask); + } + + [Fact] + public async Task ShouldNotWrapTaskCanceledExceptionOnSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new TaskCanceledException()); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + // then + await Assert.ThrowsAnyAsync(async () => await searchPatientsTask); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToOrchestrationServiceOnSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(GetRandomString()); + + // when + await this.personalDemographicsServiceClient.SearchPatientsAsync( + randomSearchCriteria, + cancellationToken); + + // then + this.pdsOrchestrationServiceMock.Verify(service => + service.SearchPatientsAsync(randomSearchCriteria, cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs new file mode 100644 index 0000000..de15450 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs @@ -0,0 +1,174 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServices +{ + public partial class PersonalDemographicsServiceClientTests + { + [Theory] + [MemberData(nameof(ValidationExceptions))] + public async Task ShouldThrowClientValidationExceptionOnSearchPatientsIfValidationErrorOccursAsync( + Xeption validationException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedException = + new PersonalDemographicsServiceClientValidationException( + message: "Personal demographics service client validation error occurred, " + + "fix errors and try again.", + + innerException: validationException.InnerException as Xeption); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(validationException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientValidationException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(DependencyValidationExceptions))] + public async Task ShouldThrowClientDependencyValidationExceptionOnSearchPatientsAsync( + Xeption dependencyValidationException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedException = + new PersonalDemographicsServiceClientDependencyValidationException( + message: "Personal demographics service client validation error occurred, " + + "fix errors and try again.", + + innerException: dependencyValidationException.InnerException as Xeption); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(dependencyValidationException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowClientDependencyExceptionOnSearchPatientsIfDependencyErrorOccursAsync( + Xeption dependencyException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedException = + new PersonalDemographicsServiceClientDependencyException( + message: "Personal demographics service client dependency error occurred, contact support.", + innerException: dependencyException.InnerException as Xeption); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(dependencyException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowClientServiceExceptionOnSearchPatientsIfServiceErrorOccursAsync( + Xeption serviceException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedException = + new PersonalDemographicsServiceClientServiceException( + message: "Personal demographics service client service error occurred, contact support.", + innerException: serviceException.InnerException as Xeption); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(serviceException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientServiceException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(UnexpectedExceptions))] + public async Task ShouldThrowClientServiceExceptionOnSearchPatientsIfUnexpectedErrorOccursAsync( + Exception unexpectedException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var failedPersonalDemographicsServiceClientException = + new FailedPersonalDemographicsServiceClientException( + message: "Unexpected error occurred, contact support.", + innerException: unexpectedException, + data: unexpectedException.Data); + + var expectedException = + new PersonalDemographicsServiceClientServiceException( + message: "Personal demographics service client service error occurred, contact support.", + innerException: failedPersonalDemographicsServiceClientException); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(unexpectedException); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientServiceException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Logic.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Logic.SearchPatients.cs new file mode 100644 index 0000000..cf835a6 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Logic.SearchPatients.cs @@ -0,0 +1,39 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServices +{ + public partial class PersonalDemographicsServiceClientTests + { + [Fact] + public async Task ShouldSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + string randomPayload = GetRandomString(); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(randomSearchCriteria, It.IsAny())) + .ReturnsAsync(randomPayload); + + // when + string actualPayload = + await this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + // then + actualPayload.Should().Be(randomPayload); + + this.pdsOrchestrationServiceMock.Verify(service => + service.SearchPatientsAsync(randomSearchCriteria, It.IsAny()), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs new file mode 100644 index 0000000..e5aa138 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs @@ -0,0 +1,87 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Clients.PersonalDemographicsServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Services.Orchestrations.Pds; +using Tynamix.ObjectFiller; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServices +{ + public partial class PersonalDemographicsServiceClientTests + { + private readonly Mock pdsOrchestrationServiceMock; + private readonly IPersonalDemographicsServiceClient personalDemographicsServiceClient; + + public PersonalDemographicsServiceClientTests() + { + this.pdsOrchestrationServiceMock = new Mock(); + + this.personalDemographicsServiceClient = + new PersonalDemographicsServiceClient(this.pdsOrchestrationServiceMock.Object); + } + + public static TheoryData ValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new PdsOrchestrationValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new PdsOrchestrationDependencyValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new PdsOrchestrationDependencyException(GetRandomString(), innerException) + }; + } + + public static TheoryData ServiceExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new PdsOrchestrationServiceException(GetRandomString(), innerException) + }; + } + + public static TheoryData UnexpectedExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new TimeoutException() + }; + + private static SearchCriteria CreateRandomSearchCriteria() => + new SearchCriteria + { + NhsNumber = GetRandomString() + }; + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs new file mode 100644 index 0000000..cd653b3 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs @@ -0,0 +1,129 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfTokenIsAlreadyCancelledAsync() + { + // given + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityService.BuildLoginUrlAsync(cancellationTokenSource.Token); + + // then + await Assert.ThrowsAnyAsync(async () => await buildLoginUrlTask); + + this.cryptoBrokerMock.Verify(broker => + broker.CreateUrlSafeState(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionOnBuildLoginUrlAsync() + { + // given + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Throws(new OperationCanceledException()); + + // when + ValueTask buildLoginUrlTask = this.careIdentityService.BuildLoginUrlAsync(); + + // then + await Assert.ThrowsAsync(async () => await buildLoginUrlTask); + } + + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionOnLogoutAsync() + { + // given + this.stateBrokerMock.Setup(broker => + broker.ClearCsrfStateAsync(It.IsAny())) + .Throws(new OperationCanceledException()); + + // when + ValueTask logoutTask = this.careIdentityService.LogoutAsync(); + + // then + await Assert.ThrowsAsync(async () => await logoutTask); + } + + [Fact] + public async Task ShouldNotWrapTaskCanceledExceptionOnGetUserInfoAsync() + { + // given + string randomAccessToken = GetRandomString(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(new TaskCanceledException()); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + // then + await Assert.ThrowsAnyAsync(async () => await getUserInfoTask); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnGetAccessTokenIfTokenIsAlreadyCancelledAsync() + { + // given + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + ValueTask getAccessTokenTask = + this.careIdentityService.GetAccessTokenAsync(cancellationTokenSource.Token); + + // then + await Assert.ThrowsAnyAsync(async () => await getAccessTokenTask); + + this.tokenBrokerMock.Verify(broker => + broker.GetAccessTokenAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnCallbackIfTokenIsAlreadyCancelledAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync( + randomCode, + randomState, + cancellationTokenSource.Token); + + // then + await Assert.ThrowsAnyAsync(async () => await callbackTask); + + this.stateBrokerMock.Verify(broker => + broker.GetCsrfStateAsync(It.IsAny()), + Times.Never); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs new file mode 100644 index 0000000..5de6b68 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs @@ -0,0 +1,284 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnBuildLoginUrlIfDependencyErrorOccursAsync( + Exception dependencyException) + { + // given + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedDependencyException(dependencyException); + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Throws(dependencyException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityService.BuildLoginUrlAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnBuildLoginUrlIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + CareIdentityServiceServiceException expectedCareIdentityServiceServiceException = + CreateExpectedServiceException(serviceException); + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Throws(serviceException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityService.BuildLoginUrlAsync(); + + CareIdentityServiceServiceException actualCareIdentityServiceServiceException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualCareIdentityServiceServiceException + .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnLogoutIfDependencyErrorOccursAsync( + Exception dependencyException) + { + // given + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedDependencyException(dependencyException); + + this.stateBrokerMock.Setup(broker => + broker.ClearCsrfStateAsync(It.IsAny())) + .Throws(dependencyException); + + // when + ValueTask logoutTask = this.careIdentityService.LogoutAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnLogoutIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + CareIdentityServiceServiceException expectedCareIdentityServiceServiceException = + CreateExpectedServiceException(serviceException); + + this.stateBrokerMock.Setup(broker => + broker.ClearCsrfStateAsync(It.IsAny())) + .Throws(serviceException); + + // when + ValueTask logoutTask = this.careIdentityService.LogoutAsync(); + + CareIdentityServiceServiceException actualCareIdentityServiceServiceException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualCareIdentityServiceServiceException + .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfDependencyErrorOccursAsync( + Exception dependencyException) + { + // given + string randomAccessToken = GetRandomString(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedDependencyException(dependencyException); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(dependencyException); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnGetUserInfoIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + string randomAccessToken = GetRandomString(); + + CareIdentityServiceServiceException expectedCareIdentityServiceServiceException = + CreateExpectedServiceException(serviceException); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(serviceException); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + CareIdentityServiceServiceException actualCareIdentityServiceServiceException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualCareIdentityServiceServiceException + .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnGetAccessTokenIfDependencyErrorOccursAsync( + Exception dependencyException) + { + // given + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedDependencyException(dependencyException); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .Throws(dependencyException); + + // when + ValueTask getAccessTokenTask = this.careIdentityService.GetAccessTokenAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await getAccessTokenTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnCallbackIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + + CareIdentityServiceServiceException expectedCareIdentityServiceServiceException = + CreateExpectedServiceException(serviceException); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .Throws(serviceException); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync(randomCode, randomState); + + CareIdentityServiceServiceException actualCareIdentityServiceServiceException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceServiceException + .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + } + + [Fact] + public async Task ShouldThrowServiceExceptionOnCallbackIfStateDoesNotMatchAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + string differentState = GetRandomString(); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .ReturnsAsync(differentState); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync(randomCode, randomState); + + CareIdentityServiceServiceException actualCareIdentityServiceServiceException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceServiceException.InnerException.InnerException + .Should().BeOfType(); + } + + private static CareIdentityServiceDependencyException CreateExpectedDependencyException( + Exception dependencyException) + { + var failedCareIdentityServiceDependencyException = + new FailedCareIdentityServiceDependencyException( + message: "Failed care identity service dependency error occurred, please contact support.", + innerException: dependencyException); + + return new CareIdentityServiceDependencyException( + message: "Care identity service dependency error occurred, please contact support.", + innerException: failedCareIdentityServiceDependencyException); + } + + private static CareIdentityServiceServiceException CreateExpectedServiceException(Exception serviceException) + { + var failedCareIdentityServiceException = + new FailedCareIdentityServiceException( + message: "Failed care identity service error occurred, please contact support.", + innerException: serviceException, + data: serviceException.Data); + + return new CareIdentityServiceServiceException( + message: "Care identity service error occurred, please contact support.", + innerException: failedCareIdentityServiceException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.BuildLoginUrl.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.BuildLoginUrl.cs new file mode 100644 index 0000000..fb45a18 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.BuildLoginUrl.cs @@ -0,0 +1,77 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldBuildLoginUrlAsync() + { + // given + string randomCsrfState = GetRandomString(); + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Returns(randomCsrfState); + + string expectedLoginUrl = + $"{this.apiPlatformConfigurations.CareIdentity.AuthEndpoint}" + + $"?client_id={this.apiPlatformConfigurations.CareIdentity.ClientId}" + + $"&redirect_uri=" + + $"{System.Uri.EscapeDataString(this.apiPlatformConfigurations.CareIdentity.RedirectUri)}" + + $"&response_type=code" + + $"&state={randomCsrfState}"; + + // when + string actualLoginUrl = await this.careIdentityService.BuildLoginUrlAsync(); + + // then + actualLoginUrl.Should().Be(expectedLoginUrl); + } + + [Fact] + public async Task ShouldStoreCsrfStateOnBuildLoginUrlAsync() + { + // given + string randomCsrfState = GetRandomString(); + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Returns(randomCsrfState); + + // when + await this.careIdentityService.BuildLoginUrlAsync(); + + // then + this.stateBrokerMock.Verify(broker => + broker.StoreCsrfStateAsync(randomCsrfState, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldAppendAcrValuesOnBuildLoginUrlIfAcrValuesAreConfiguredAsync() + { + // given + string randomAcrValues = GetRandomString(); + this.apiPlatformConfigurations.CareIdentity.AcrValues = randomAcrValues; + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Returns(GetRandomString()); + + // when + string actualLoginUrl = await this.careIdentityService.BuildLoginUrlAsync(); + + // then + actualLoginUrl.Should().EndWith($"&acr_values={randomAcrValues}"); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Callback.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Callback.cs new file mode 100644 index 0000000..45a8168 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Callback.cs @@ -0,0 +1,198 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldStoreAccessTokenOnCallbackAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + + DateTimeOffset expectedExpiresAtUtc = + randomDateTimeOffset.AddSeconds(int.Parse(randomTokenResult.ExpiresIn)); + + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreAccessTokenAsync( + randomTokenResult.AccessToken, + expectedExpiresAtUtc, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldStoreRefreshTokenOnCallbackAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + + DateTimeOffset expectedExpiresAtUtc = + randomDateTimeOffset.AddSeconds(int.Parse(randomTokenResult.RefreshTokenExpiresIn)); + + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreRefreshTokenAsync( + randomTokenResult.RefreshToken, + expectedExpiresAtUtc, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldNotStoreRefreshTokenOnCallbackIfRefreshTokenIsMissingAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + randomTokenResult.RefreshToken = string.Empty; + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldClearCsrfStateOnCallbackAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.stateBrokerMock.Verify(broker => + broker.ClearCsrfStateAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldStoreActiveRoleOnCallbackIfUserHasRolesAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + string expectedRoleId = randomUserInfo.NhsIdNrbacRoles[0].PersonRoleId; + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreActiveRoleAsync(expectedRoleId, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldNotStoreActiveRoleOnCallbackIfUserHasNoRolesAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + randomUserInfo.NhsIdNrbacRoles = new List(); + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreActiveRoleAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + private void SetupSuccessfulCallback( + string csrfState, + DateTimeOffset currentDateTimeOffset, + TokenResult tokenResult, + NhsUserInfo userInfo) + { + string randomTokenJson = GetRandomString(); + string randomUserInfoJson = GetRandomString(); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .ReturnsAsync(csrfState); + + this.httpBrokerMock.Setup(broker => + broker.PostFormAsync( + this.apiPlatformConfigurations.CareIdentity.TokenEndpoint, + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomTokenJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomTokenJson)) + .Returns(tokenResult); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + this.apiPlatformConfigurations.CareIdentity.UserInfoEndpoint, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomUserInfoJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomUserInfoJson)) + .Returns(userInfo); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(currentDateTimeOffset); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs new file mode 100644 index 0000000..a7574d5 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs @@ -0,0 +1,165 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldReturnStoredAccessTokenOnGetAccessTokenIfTokenIsStillValidAsync() + { + // given + string randomAccessToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + DateTimeOffset expiresAtUtc = randomDateTimeOffset.AddMinutes(30); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((randomAccessToken, expiresAtUtc)); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomAccessToken); + } + + [Fact] + public async Task ShouldReturnEmptyStringOnGetAccessTokenIfRefreshTokenIsMissingAsync() + { + // given + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.tokenBrokerMock.Setup(broker => + broker.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().BeEmpty(); + } + + [Fact] + public async Task ShouldReturnEmptyStringOnGetAccessTokenIfRefreshTokenHasExpiredAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + DateTimeOffset expiredAtUtc = randomDateTimeOffset.AddMinutes(-1); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.tokenBrokerMock.Setup(broker => + broker.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((randomRefreshToken, expiredAtUtc)); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().BeEmpty(); + } + + [Fact] + public async Task ShouldRefreshAccessTokenOnGetAccessTokenIfStoredTokenHasExpiredAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + SetupExpiredAccessTokenWithValidRefreshToken(randomRefreshToken, randomDateTimeOffset, randomTokenResult); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomTokenResult.AccessToken); + } + + [Fact] + public async Task ShouldStoreRefreshedAccessTokenOnGetAccessTokenAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + + DateTimeOffset expectedExpiresAtUtc = + randomDateTimeOffset.AddSeconds(int.Parse(randomTokenResult.ExpiresIn)); + + SetupExpiredAccessTokenWithValidRefreshToken(randomRefreshToken, randomDateTimeOffset, randomTokenResult); + + // when + await this.careIdentityService.GetAccessTokenAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreAccessTokenAsync( + randomTokenResult.AccessToken, + expectedExpiresAtUtc, + It.IsAny()), + Times.Once); + } + + private void SetupExpiredAccessTokenWithValidRefreshToken( + string refreshToken, + DateTimeOffset currentDateTimeOffset, + TokenResult refreshedTokenResult) + { + string randomTokenJson = GetRandomString(); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.tokenBrokerMock.Setup(broker => + broker.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((refreshToken, currentDateTimeOffset.AddMinutes(30))); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(currentDateTimeOffset); + + this.httpBrokerMock.Setup(broker => + broker.PostFormAsync( + this.apiPlatformConfigurations.CareIdentity.TokenEndpoint, + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomTokenJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomTokenJson)) + .Returns(refreshedTokenResult); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetUserInfo.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetUserInfo.cs new file mode 100644 index 0000000..425b0ef --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetUserInfo.cs @@ -0,0 +1,75 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldReturnUserInfoOnGetUserInfoAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomUserInfoJson = GetRandomString(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + this.apiPlatformConfigurations.CareIdentity.UserInfoEndpoint, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomUserInfoJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomUserInfoJson)) + .Returns(randomUserInfo); + + // when + NhsUserInfo actualUserInfo = + await this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + // then + actualUserInfo.Should().BeSameAs(randomUserInfo); + } + + [Fact] + public async Task ShouldCallUserInfoEndpointOnGetUserInfoAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomUserInfoJson = GetRandomString(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomUserInfoJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomUserInfoJson)) + .Returns(CreateRandomNhsUserInfo()); + + // when + await this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + // then + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + this.apiPlatformConfigurations.CareIdentity.UserInfoEndpoint, + It.IsAny>(), + It.IsAny()), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Logout.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Logout.cs new file mode 100644 index 0000000..4b78462 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.Logout.cs @@ -0,0 +1,66 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldClearCsrfStateOnLogoutAsync() + { + // given + // when + await this.careIdentityService.LogoutAsync(); + + // then + this.stateBrokerMock.Verify(broker => + broker.ClearCsrfStateAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldClearAccessTokenOnLogoutAsync() + { + // given + // when + await this.careIdentityService.LogoutAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.ClearAccessTokenAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldClearRefreshTokenOnLogoutAsync() + { + // given + // when + await this.careIdentityService.LogoutAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.ClearRefreshTokenAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldClearActiveRoleOnLogoutAsync() + { + // given + // when + await this.careIdentityService.LogoutAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.ClearActiveRoleAsync(It.IsAny()), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs new file mode 100644 index 0000000..1b70e35 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs @@ -0,0 +1,186 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnCallbackIfCodeIsInvalidAsync(string invalidCode) + { + // given + string randomState = GetRandomString(); + + var invalidArgumentCareIdentityServiceException = + new InvalidArgumentCareIdentityServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentCareIdentityServiceException.UpsertDataList( + key: "code", + value: "Text is required"); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidArgumentCareIdentityServiceException); + + // when + ValueTask callbackTask = + this.careIdentityService.CallbackAsync(invalidCode, randomState); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + } + + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnCallbackIfStateIsInvalidAsync(string invalidState) + { + // given + string randomCode = GetRandomString(); + + var invalidArgumentCareIdentityServiceException = + new InvalidArgumentCareIdentityServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentCareIdentityServiceException.UpsertDataList( + key: "state", + value: "Text is required"); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidArgumentCareIdentityServiceException); + + // when + ValueTask callbackTask = + this.careIdentityService.CallbackAsync(randomCode, invalidState); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + } + + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnGetUserInfoIfAccessTokenIsInvalidAsync( + string invalidAccessToken) + { + // given + var invalidArgumentCareIdentityServiceException = + new InvalidArgumentCareIdentityServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentCareIdentityServiceException.UpsertDataList( + key: "accessToken", + value: "Text is required"); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidArgumentCareIdentityServiceException); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(invalidAccessToken, default); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnGetAccessTokenIfRefreshedTokenIsEmptyAsync() + { + // given + string randomRefreshToken = GetRandomString(); + string randomTokenJson = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult emptyTokenResult = CreateRandomTokenResult(); + emptyTokenResult.AccessToken = string.Empty; + + var unauthorisedCareIdentityServiceException = + new UnauthorisedCareIdentityServiceException( + message: "Authentication failed (no access token)."); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: unauthorisedCareIdentityServiceException); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.tokenBrokerMock.Setup(broker => + broker.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((randomRefreshToken, randomDateTimeOffset.AddMinutes(30))); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + this.httpBrokerMock.Setup(broker => + broker.PostFormAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomTokenJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomTokenJson)) + .Returns(emptyTokenResult); + + // when + ValueTask getAccessTokenTask = this.careIdentityService.GetAccessTokenAsync(); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await getAccessTokenTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs new file mode 100644 index 0000000..942d4f6 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs @@ -0,0 +1,141 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Brokers.Cryptographies; +using NHSDigital.ApiPlatform.Sdk.Brokers.DateTimes; +using NHSDigital.ApiPlatform.Sdk.Brokers.Https; +using NHSDigital.ApiPlatform.Sdk.Brokers.Serializations; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; +using Tynamix.ObjectFiller; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + private readonly Mock httpBrokerMock; + private readonly Mock jsonBrokerMock; + private readonly Mock cryptoBrokerMock; + private readonly Mock dateTimeBrokerMock; + private readonly Mock stateBrokerMock; + private readonly Mock tokenBrokerMock; + private readonly ApiPlatformConfigurations apiPlatformConfigurations; + private readonly ICareIdentityService careIdentityService; + + public CareIdentityServiceTests() + { + this.httpBrokerMock = new Mock(); + this.jsonBrokerMock = new Mock(); + this.cryptoBrokerMock = new Mock(); + this.dateTimeBrokerMock = new Mock(); + this.stateBrokerMock = new Mock(); + this.tokenBrokerMock = new Mock(); + this.apiPlatformConfigurations = CreateRandomConfigurations(); + + this.careIdentityService = new CareIdentityService( + configurations: this.apiPlatformConfigurations, + httpBroker: this.httpBrokerMock.Object, + jsonBroker: this.jsonBrokerMock.Object, + cryptoBroker: this.cryptoBrokerMock.Object, + dateTimeBroker: this.dateTimeBrokerMock.Object, + stateBroker: this.stateBrokerMock.Object, + tokenBroker: this.tokenBrokerMock.Object); + } + + public static TheoryData DependencyExceptions() => + new TheoryData + { + new HttpRequestException(), + new TimeoutException() + }; + + public static TheoryData ServiceExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new NotSupportedException() + }; + + public static TheoryData InvalidTexts() => + new TheoryData + { + null, + string.Empty, + " " + }; + + private static ApiPlatformConfigurations CreateRandomConfigurations() => + new ApiPlatformConfigurations + { + CareIdentity = new CareIdentityConfigurations + { + ClientId = GetRandomString(), + ClientSecret = GetRandomString(), + RedirectUri = $"https://{GetRandomString()}/callback", + AuthEndpoint = $"https://{GetRandomString()}/authorize", + TokenEndpoint = $"https://{GetRandomString()}/token", + UserInfoEndpoint = $"https://{GetRandomString()}/userinfo", + AcrValues = null + }, + + PersonalDemographicsService = new PersonalDemographicsServiceConfigurations + { + BaseUrl = $"https://{GetRandomString()}/fhir" + } + }; + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static int GetRandomNumber() => + new IntRange(min: 100, max: 900).GetValue(); + + private static DateTimeOffset GetRandomDateTimeOffset() => + new DateTimeRange(earliestDate: new DateTime()).GetValue(); + + private static HttpResponseMessage CreateHttpResponse(string content) => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content) + }; + + private static TokenResult CreateRandomTokenResult() => + new TokenResult + { + AccessToken = GetRandomString(), + RefreshToken = GetRandomString(), + TokenType = "Bearer", + ExpiresIn = GetRandomNumber().ToString(), + RefreshTokenExpiresIn = GetRandomNumber().ToString() + }; + + private static NhsUserInfo CreateRandomNhsUserInfo() => + new NhsUserInfo + { + NhsIdUserUid = GetRandomString(), + Name = GetRandomString(), + Sub = GetRandomString(), + NhsIdNrbacRoles = new List + { + new NhsNrbacRole + { + PersonRoleId = GetRandomString(), + PersonOrgId = GetRandomString(), + OrgCode = GetRandomString(), + RoleName = GetRandomString(), + RoleCode = GetRandomString() + } + } + }; + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs new file mode 100644 index 0000000..025ff32 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs @@ -0,0 +1,122 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfTokenIsAlreadyCancelledAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + ValueTask searchPatientsTask = this.pdsService.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria, + cancellationTokenSource.Token); + + // then + await Assert.ThrowsAnyAsync(async () => await searchPatientsTask); + + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(new OperationCanceledException()); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + await Assert.ThrowsAsync(async () => await searchPatientsTask); + } + + [Fact] + public async Task ShouldNotWrapTaskCanceledExceptionOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(new TaskCanceledException()); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + await Assert.ThrowsAnyAsync(async () => await searchPatientsTask); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToBrokersOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria, + cancellationToken); + + // then + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + cancellationToken), + Times.Once); + + this.tokenBrokerMock.Verify(broker => + broker.GetActiveRoleAsync(cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs new file mode 100644 index 0000000..5e0665c --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs @@ -0,0 +1,116 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfDependencyErrorOccursAsync( + Exception dependencyException) + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + PdsServiceDependencyException expectedPdsServiceDependencyException = + CreateExpectedDependencyException(dependencyException); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(dependencyException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyException actualPdsServiceDependencyException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceDependencyException + .Should().BeEquivalentTo(expectedPdsServiceDependencyException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnSearchPatientsIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + PdsServiceException expectedPdsServiceException = + CreateExpectedServiceException(serviceException); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(serviceException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceException actualPdsServiceException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceException.Should().BeEquivalentTo(expectedPdsServiceException); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfResponseIsUnsuccessfulAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + var unsuccessfulResponse = + new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent(GetRandomString()) + }; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(unsuccessfulResponse); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyException actualPdsServiceDependencyException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceDependencyException.InnerException.InnerException + .Should().BeOfType(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Logic.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Logic.SearchPatients.cs new file mode 100644 index 0000000..38ccbdd --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Logic.SearchPatients.cs @@ -0,0 +1,193 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Fact] + public async Task ShouldReturnPatientPayloadOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomPayload = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(randomPayload)); + + // when + string actualPayload = await this.pdsService.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria); + + // then + actualPayload.Should().Be(randomPayload); + } + + [Fact] + public async Task ShouldSearchByNhsNumberOnSearchPatientsIfNhsNumberIsSuppliedAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + string expectedUrl = + $"{this.apiPlatformConfigurations.PersonalDemographicsService.BaseUrl}" + + $"/Patient/{randomSearchCriteria.NhsNumber}"; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + expectedUrl, + It.IsAny>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldSearchByDemographicsOnSearchPatientsIfNhsNumberIsMissingAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithDemographics(); + + string expectedUrl = + $"{this.apiPlatformConfigurations.PersonalDemographicsService.BaseUrl}" + + $"/Patient?family={Uri.EscapeDataString(randomSearchCriteria.Surname)}" + + $"&given={Uri.EscapeDataString(randomSearchCriteria.FirstName)}" + + $"&gender={Uri.EscapeDataString(randomSearchCriteria.Gender)}" + + $"&birthdate=eq{randomSearchCriteria.DateOfBirth}" + + $"&address-postalcode={Uri.EscapeDataString(randomSearchCriteria.Postcode)}"; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + expectedUrl, + It.IsAny>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldRetrieveActiveRoleOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + this.tokenBrokerMock.Verify(broker => + broker.GetActiveRoleAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldAddAuthorisationAndTracingHeadersOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomActiveRoleId = GetRandomString(); + Guid randomRequestId = Guid.NewGuid(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + var actualRequest = new HttpRequestMessage(HttpMethod.Get, "https://localhost/patient"); + + this.tokenBrokerMock.Setup(broker => + broker.GetActiveRoleAsync(It.IsAny())) + .ReturnsAsync(randomActiveRoleId); + + this.identifierBrokerMock.Setup(broker => + broker.GetNewGuid()) + .Returns(randomRequestId); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (url, configureRequest, cancellationToken) => configureRequest(actualRequest)) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + actualRequest.Headers.Authorization.Parameter.Should().Be(randomAccessToken); + actualRequest.Headers.GetValues("X-Request-ID").Should().Contain(randomRequestId.ToString()); + actualRequest.Headers.GetValues("NHSD-Session-URID").Should().Contain(randomActiveRoleId); + } + + [Fact] + public async Task ShouldNotAddSessionRoleHeaderOnSearchPatientsIfActiveRoleIsMissingAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + var actualRequest = new HttpRequestMessage(HttpMethod.Get, "https://localhost/patient"); + + this.tokenBrokerMock.Setup(broker => + broker.GetActiveRoleAsync(It.IsAny())) + .ReturnsAsync((string)null); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (url, configureRequest, cancellationToken) => configureRequest(actualRequest)) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + // then + actualRequest.Headers.Contains("NHSD-Session-URID").Should().BeFalse(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs new file mode 100644 index 0000000..d2aaea4 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs @@ -0,0 +1,122 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfSearchCriteriaIsNullAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria nullSearchCriteria = null; + + var nullSearchCriteriaPdsServiceException = + new NullSearchCriteriaPdsServiceException( + message: "Search criteria is null."); + + var expectedPdsServiceValidationException = + new PdsServiceValidationException( + message: "PDS service validation error occurred, please fix the errors and try again.", + innerException: nullSearchCriteriaPdsServiceException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, nullSearchCriteria); + + PdsServiceValidationException actualPdsServiceValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceValidationException + .Should().BeEquivalentTo(expectedPdsServiceValidationException); + + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfAccessTokenIsInvalidAsync( + string invalidAccessToken) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + var invalidArgumentPdsServiceException = + new InvalidArgumentPdsServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentPdsServiceException.UpsertDataList( + key: "accessToken", + value: "Text is required"); + + var expectedPdsServiceValidationException = + new PdsServiceValidationException( + message: "PDS service validation error occurred, please fix the errors and try again.", + innerException: invalidArgumentPdsServiceException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(invalidAccessToken, randomSearchCriteria); + + PdsServiceValidationException actualPdsServiceValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceValidationException + .Should().BeEquivalentTo(expectedPdsServiceValidationException); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfNhsNumberAndSurnameAreMissingAsync() + { + // given + string randomAccessToken = GetRandomString(); + var emptySearchCriteria = new SearchCriteria(); + + var invalidArgumentPdsServiceException = + new InvalidArgumentPdsServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentPdsServiceException.UpsertDataList( + key: "searchCriteria", + value: "Either an NHS number or a surname is required"); + + var expectedPdsServiceValidationException = + new PdsServiceValidationException( + message: "PDS service validation error occurred, please fix the errors and try again.", + innerException: invalidArgumentPdsServiceException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, emptySearchCriteria); + + PdsServiceValidationException actualPdsServiceValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceValidationException + .Should().BeEquivalentTo(expectedPdsServiceValidationException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs new file mode 100644 index 0000000..3537397 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs @@ -0,0 +1,124 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net; +using System.Net.Http; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Brokers.Https; +using NHSDigital.ApiPlatform.Sdk.Brokers.Identifiers; +using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; +using NHSDigital.ApiPlatform.Sdk.Models.Configurations; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds; +using Tynamix.ObjectFiller; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + private readonly Mock httpBrokerMock; + private readonly Mock identifierBrokerMock; + private readonly Mock tokenBrokerMock; + private readonly ApiPlatformConfigurations apiPlatformConfigurations; + private readonly IPdsService pdsService; + + public PdsServiceTests() + { + this.httpBrokerMock = new Mock(); + this.identifierBrokerMock = new Mock(); + this.tokenBrokerMock = new Mock(); + + this.apiPlatformConfigurations = new ApiPlatformConfigurations + { + PersonalDemographicsService = new PersonalDemographicsServiceConfigurations + { + BaseUrl = $"https://{GetRandomString()}/fhir" + } + }; + + this.pdsService = new PdsService( + configurations: this.apiPlatformConfigurations, + httpBroker: this.httpBrokerMock.Object, + identifierBroker: this.identifierBrokerMock.Object, + tokenBroker: this.tokenBrokerMock.Object); + } + + public static TheoryData DependencyExceptions() => + new TheoryData + { + new HttpRequestException(), + new TimeoutException() + }; + + public static TheoryData ServiceExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new NotSupportedException() + }; + + public static TheoryData InvalidTexts() => + new TheoryData + { + null, + string.Empty, + " " + }; + + private static SearchCriteria CreateRandomSearchCriteriaWithNhsNumber() => + new SearchCriteria + { + NhsNumber = GetRandomString() + }; + + private static SearchCriteria CreateRandomSearchCriteriaWithDemographics() => + new SearchCriteria + { + Surname = GetRandomString(), + FirstName = GetRandomString(), + Gender = GetRandomString(), + DateOfBirth = "1980-01-01", + Postcode = GetRandomString() + }; + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static HttpResponseMessage CreateHttpResponse(string content) => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content) + }; + + private static PdsServiceDependencyException CreateExpectedDependencyException( + Exception dependencyException) + { + var failedPdsServiceDependencyException = + new FailedPdsServiceDependencyException( + message: "Failed PDS service dependency error occurred, please contact support.", + innerException: dependencyException); + + return new PdsServiceDependencyException( + message: "PDS service dependency error occurred, please contact support.", + innerException: failedPdsServiceDependencyException); + } + + private static PdsServiceException CreateExpectedServiceException(Exception serviceException) + { + var failedPdsServiceException = + new FailedPdsServiceException( + message: "Failed PDS service error occurred, please contact support.", + innerException: serviceException, + data: serviceException.Data); + + return new PdsServiceException( + message: "PDS service error occurred, please contact support.", + innerException: failedPdsServiceException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs new file mode 100644 index 0000000..e2a23f5 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs @@ -0,0 +1,88 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds +{ + public partial class PdsOrchestrationServiceTests + { + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfTokenIsAlreadyCancelledAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + ValueTask searchPatientsTask = this.pdsOrchestrationService.SearchPatientsAsync( + randomSearchCriteria, + cancellationTokenSource.Token); + + // then + await Assert.ThrowsAnyAsync(async () => await searchPatientsTask); + + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + // then + await Assert.ThrowsAsync(async () => await searchPatientsTask); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToDependenciesOnSearchPatientsAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + string randomAccessToken = GetRandomString(); + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.pdsServiceMock.Setup(service => + service.SearchPatientsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(GetRandomString()); + + // when + await this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria, cancellationToken); + + // then + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(cancellationToken), + Times.Once); + + this.pdsServiceMock.Verify(service => + service.SearchPatientsAsync(randomAccessToken, randomSearchCriteria, cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs new file mode 100644 index 0000000..4236bcb --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs @@ -0,0 +1,146 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds +{ + public partial class PdsOrchestrationServiceTests + { + [Theory] + [MemberData(nameof(DependencyValidationExceptions))] + public async Task ShouldThrowDependencyValidationExceptionOnSearchPatientsIfDependencyValidationErrorOccursAsync( + Xeption dependencyValidationException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedPdsOrchestrationDependencyValidationException = + new PdsOrchestrationDependencyValidationException( + message: "PDS orchestration dependency validation error occurred, fix the errors and try again.", + innerException: dependencyValidationException.InnerException as Xeption); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(dependencyValidationException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedPdsOrchestrationDependencyValidationException); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfDependencyErrorOccursAsync( + Xeption dependencyException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var expectedPdsOrchestrationDependencyException = + new PdsOrchestrationDependencyException( + message: "PDS orchestration dependency error occurred, fix the errors and try again.", + innerException: dependencyException.InnerException as Xeption); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(dependencyException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedPdsOrchestrationDependencyException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnSearchPatientsIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + PdsOrchestrationServiceException expectedPdsOrchestrationServiceException = + CreateExpectedServiceException(serviceException); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(serviceException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationServiceException actualPdsOrchestrationServiceException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsOrchestrationServiceException + .Should().BeEquivalentTo(expectedPdsOrchestrationServiceException); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfTimeoutOccursInFoundationServiceAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + var timeoutException = new TimeoutException(); + + var failedPdsServiceDependencyException = + new NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions + .FailedPdsServiceDependencyException( + message: "Failed PDS service dependency error occurred, please contact support.", + innerException: timeoutException); + + var pdsServiceDependencyException = + new NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions.PdsServiceDependencyException( + message: "PDS service dependency error occurred, please contact support.", + innerException: failedPdsServiceDependencyException); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(GetRandomString()); + + this.pdsServiceMock.Setup(service => + service.SearchPatientsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(pdsServiceDependencyException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.InnerException.InnerException.Should().BeOfType(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Logic.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Logic.SearchPatients.cs new file mode 100644 index 0000000..76914d8 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Logic.SearchPatients.cs @@ -0,0 +1,77 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds +{ + public partial class PdsOrchestrationServiceTests + { + [Fact] + public async Task ShouldReturnPatientPayloadOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomPayload = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.pdsServiceMock.Setup(service => + service.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria, + It.IsAny())) + .ReturnsAsync(randomPayload); + + // when + string actualPayload = + await this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + // then + actualPayload.Should().Be(randomPayload); + } + + [Fact] + public async Task ShouldRetrieveAccessTokenBeforeSearchingPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.pdsServiceMock.Setup(service => + service.SearchPatientsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(GetRandomString()); + + // when + await this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + // then + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(It.IsAny()), + Times.Once); + + this.pdsServiceMock.Verify(service => + service.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria, + It.IsAny()), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs new file mode 100644 index 0000000..e628c12 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs @@ -0,0 +1,120 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds +{ + public partial class PdsOrchestrationServiceTests + { + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfSearchCriteriaIsNullAsync() + { + // given + SearchCriteria nullSearchCriteria = null; + + var nullSearchCriteriaPdsOrchestrationException = + new NullSearchCriteriaPdsOrchestrationException( + message: "Search criteria is null."); + + var expectedPdsOrchestrationValidationException = + new PdsOrchestrationValidationException( + message: "PDS orchestration validation error occurred, fix the errors and try again.", + innerException: nullSearchCriteriaPdsOrchestrationException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(nullSearchCriteria); + + PdsOrchestrationValidationException actualPdsOrchestrationValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsOrchestrationValidationException + .Should().BeEquivalentTo(expectedPdsOrchestrationValidationException); + + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfNhsNumberAndSurnameAreMissingAsync() + { + // given + var emptySearchCriteria = new SearchCriteria(); + + var invalidArgumentPdsOrchestrationException = + new InvalidArgumentPdsOrchestrationException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentPdsOrchestrationException.UpsertDataList( + key: "searchCriteria", + value: "Either an NHS number or a surname is required"); + + var expectedPdsOrchestrationValidationException = + new PdsOrchestrationValidationException( + message: "PDS orchestration validation error occurred, fix the errors and try again.", + innerException: invalidArgumentPdsOrchestrationException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(emptySearchCriteria); + + PdsOrchestrationValidationException actualPdsOrchestrationValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsOrchestrationValidationException + .Should().BeEquivalentTo(expectedPdsOrchestrationValidationException); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfAccessTokenIsUnavailableAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + + var unauthorizedPdsOrchestrationException = + new UnauthorizedPdsOrchestrationException( + message: "Unauthorized - Unable to retrieve access token."); + + var expectedPdsOrchestrationValidationException = + new PdsOrchestrationValidationException( + message: "PDS orchestration validation error occurred, fix the errors and try again.", + innerException: unauthorizedPdsOrchestrationException); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(string.Empty); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationValidationException actualPdsOrchestrationValidationException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsOrchestrationValidationException + .Should().BeEquivalentTo(expectedPdsOrchestrationValidationException); + + this.pdsServiceMock.Verify(service => + service.SearchPatientsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs new file mode 100644 index 0000000..eb89d8e --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs @@ -0,0 +1,92 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Services.Orchestrations.Pds; +using Tynamix.ObjectFiller; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds +{ + public partial class PdsOrchestrationServiceTests + { + private readonly Mock careIdentityServiceMock; + private readonly Mock pdsServiceMock; + private readonly IPdsOrchestrationService pdsOrchestrationService; + + public PdsOrchestrationServiceTests() + { + this.careIdentityServiceMock = new Mock(); + this.pdsServiceMock = new Mock(); + + this.pdsOrchestrationService = new PdsOrchestrationService( + careIdentityService: this.careIdentityServiceMock.Object, + pdsService: this.pdsServiceMock.Object); + } + + public static TheoryData DependencyValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceValidationException(GetRandomString(), innerException), + new CareIdentityServiceDependencyValidationException(GetRandomString(), innerException), + new PdsServiceValidationException(GetRandomString(), innerException), + new PdsServiceDependencyValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceDependencyException(GetRandomString(), innerException), + new CareIdentityServiceServiceException(GetRandomString(), innerException), + new PdsServiceDependencyException(GetRandomString(), innerException), + new PdsServiceException(GetRandomString(), innerException) + }; + } + + public static TheoryData ServiceExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new NotSupportedException() + }; + + private static SearchCriteria CreateRandomSearchCriteria() => + new SearchCriteria + { + NhsNumber = GetRandomString() + }; + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static PdsOrchestrationServiceException CreateExpectedServiceException(Exception serviceException) + { + var failedPdsOrchestrationException = + new FailedPdsOrchestrationException( + message: "Failed PDS orchestration service error occurred, please contact support.", + innerException: serviceException, + data: serviceException.Data); + + return new PdsOrchestrationServiceException( + message: "PDS orchestration service error occurred, please contact support.", + innerException: failedPdsOrchestrationException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs new file mode 100644 index 0000000..15dcd6d --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs @@ -0,0 +1,146 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices +{ + public partial class CareIdentityServiceProcessingServiceTests + { + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfTokenIsAlreadyCancelledAsync() + { + // given + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(cancellationTokenSource.Token); + + // then + await Assert.ThrowsAnyAsync(async () => await buildLoginUrlTask); + + this.careIdentityServiceMock.Verify(service => + service.BuildLoginUrlAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnLogoutIfTokenIsAlreadyCancelledAsync() + { + // given + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + ValueTask logoutTask = + this.careIdentityServiceProcessingService.LogoutAsync(cancellationTokenSource.Token); + + // then + await Assert.ThrowsAnyAsync(async () => await logoutTask); + + this.careIdentityServiceMock.Verify(service => + service.LogoutAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnGetUserInfoIfTokenIsAlreadyCancelledAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceProcessingService.GetUserInfoAsync( + randomCode, + randomState, + cancellationTokenSource.Token); + + // then + await Assert.ThrowsAnyAsync(async () => await getUserInfoTask); + + this.careIdentityServiceMock.Verify(service => + service.CallbackAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionOnGetAccessTokenAsync() + { + // given + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + // when + ValueTask getAccessTokenTask = + this.careIdentityServiceProcessingService.GetAccessTokenAsync(); + + // then + await Assert.ThrowsAsync(async () => await getAccessTokenTask); + } + + [Fact] + public async Task ShouldNotWrapTaskCanceledExceptionOnLogoutAsync() + { + // given + this.careIdentityServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(new TaskCanceledException()); + + // when + ValueTask logoutTask = this.careIdentityServiceProcessingService.LogoutAsync(); + + // then + await Assert.ThrowsAnyAsync(async () => await logoutTask); + } + + [Fact] + public async Task ShouldPropagateCancellationTokenToFoundationServiceOnGetUserInfoAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + string randomAccessToken = GetRandomString(); + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken cancellationToken = cancellationTokenSource.Token; + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.careIdentityServiceMock.Setup(service => + service.GetUserInfoAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateRandomNhsUserInfo()); + + // when + await this.careIdentityServiceProcessingService.GetUserInfoAsync( + randomCode, + randomState, + cancellationToken); + + // then + this.careIdentityServiceMock.Verify(service => + service.CallbackAsync(randomCode, randomState, cancellationToken), + Times.Once); + + this.careIdentityServiceMock.Verify(service => + service.GetUserInfoAsync(randomAccessToken, cancellationToken), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs new file mode 100644 index 0000000..35c6861 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs @@ -0,0 +1,181 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices +{ + public partial class CareIdentityServiceProcessingServiceTests + { + [Theory] + [MemberData(nameof(DependencyValidationExceptions))] + public async Task ShouldThrowDependencyValidationExceptionOnBuildLoginUrlIfDependencyValidationErrorOccursAsync( + Xeption dependencyValidationException) + { + // given + var expectedException = + new CareIdentityServiceProcessingDependencyValidationException( + message: "Care identity service processing dependency validation error occurred, " + + "please fix the errors and try again.", + + innerException: dependencyValidationException.InnerException as Xeption); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(dependencyValidationException); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + CareIdentityServiceProcessingDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnBuildLoginUrlIfDependencyErrorOccursAsync( + Xeption dependencyException) + { + // given + var expectedException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: dependencyException.InnerException as Xeption); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(dependencyException); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + CareIdentityServiceProcessingDependencyException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnBuildLoginUrlIfServiceErrorOccursAsync( + Exception serviceException) + { + // given + CareIdentityServiceProcessingServiceException expectedException = + CreateExpectedServiceException(serviceException); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(serviceException); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + CareIdentityServiceProcessingServiceException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(DependencyExceptions))] + public async Task ShouldThrowDependencyExceptionOnLogoutIfDependencyErrorOccursAsync( + Xeption dependencyException) + { + // given + var expectedException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: dependencyException.InnerException as Xeption); + + this.careIdentityServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(dependencyException); + + // when + ValueTask logoutTask = this.careIdentityServiceProcessingService.LogoutAsync(); + + CareIdentityServiceProcessingDependencyException actualException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(ServiceExceptions))] + public async Task ShouldThrowServiceExceptionOnLogoutIfServiceErrorOccursAsync(Exception serviceException) + { + // given + CareIdentityServiceProcessingServiceException expectedException = + CreateExpectedServiceException(serviceException); + + this.careIdentityServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(serviceException); + + // when + ValueTask logoutTask = this.careIdentityServiceProcessingService.LogoutAsync(); + + CareIdentityServiceProcessingServiceException actualException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnGetAccessTokenIfTimeoutOccursInFoundationServiceAsync() + { + // given + var timeoutException = new TimeoutException(); + + var failedCareIdentityServiceDependencyException = + new FailedCareIdentityServiceDependencyException( + message: "Failed care identity service dependency error occurred, please contact support.", + innerException: timeoutException); + + var careIdentityServiceDependencyException = + new CareIdentityServiceDependencyException( + message: "Care identity service dependency error occurred, please contact support.", + innerException: failedCareIdentityServiceDependencyException); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(careIdentityServiceDependencyException); + + // when + ValueTask getAccessTokenTask = + this.careIdentityServiceProcessingService.GetAccessTokenAsync(); + + CareIdentityServiceProcessingDependencyException actualException = + await Assert.ThrowsAsync( + async () => await getAccessTokenTask); + + // then + actualException.InnerException.InnerException.Should().BeOfType(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Logic.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Logic.cs new file mode 100644 index 0000000..a5d5eac --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Logic.cs @@ -0,0 +1,117 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices +{ + public partial class CareIdentityServiceProcessingServiceTests + { + [Fact] + public async Task ShouldBuildLoginUrlAsync() + { + // given + string randomLoginUrl = GetRandomString(); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ReturnsAsync(randomLoginUrl); + + // when + string actualLoginUrl = await this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + // then + actualLoginUrl.Should().Be(randomLoginUrl); + + this.careIdentityServiceMock.Verify(service => + service.BuildLoginUrlAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldLogoutAsync() + { + // given + // when + await this.careIdentityServiceProcessingService.LogoutAsync(); + + // then + this.careIdentityServiceMock.Verify(service => + service.LogoutAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldGetAccessTokenAsync() + { + // given + string randomAccessToken = GetRandomString(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + // when + string actualAccessToken = await this.careIdentityServiceProcessingService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomAccessToken); + } + + [Fact] + public async Task ShouldReturnUserInfoOnGetUserInfoAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + string randomAccessToken = GetRandomString(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.careIdentityServiceMock.Setup(service => + service.GetUserInfoAsync(randomAccessToken, It.IsAny())) + .ReturnsAsync(randomUserInfo); + + // when + NhsUserInfo actualUserInfo = + await this.careIdentityServiceProcessingService.GetUserInfoAsync(randomCode, randomState); + + // then + actualUserInfo.Should().BeSameAs(randomUserInfo); + } + + [Fact] + public async Task ShouldCompleteCallbackBeforeRetrievingUserInfoOnGetUserInfoAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + string randomAccessToken = GetRandomString(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(randomAccessToken); + + this.careIdentityServiceMock.Setup(service => + service.GetUserInfoAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateRandomNhsUserInfo()); + + // when + await this.careIdentityServiceProcessingService.GetUserInfoAsync(randomCode, randomState); + + // then + this.careIdentityServiceMock.Verify(service => + service.CallbackAsync(randomCode, randomState, It.IsAny()), + Times.Once); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs new file mode 100644 index 0000000..9a82bec --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs @@ -0,0 +1,132 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices +{ + public partial class CareIdentityServiceProcessingServiceTests + { + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnGetUserInfoIfCodeIsInvalidAsync(string invalidCode) + { + // given + string randomState = GetRandomString(); + + var invalidArgumentCareIdentityServiceProcessingException = + new InvalidArgumentCareIdentityServiceProcessingException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentCareIdentityServiceProcessingException.UpsertDataList( + key: "code", + value: "Text is required"); + + var expectedException = + new CareIdentityServiceProcessingValidationException( + message: "Care identity service processing validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidArgumentCareIdentityServiceProcessingException); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceProcessingService.GetUserInfoAsync(invalidCode, randomState); + + CareIdentityServiceProcessingValidationException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.careIdentityServiceMock.Verify(service => + service.CallbackAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnGetUserInfoIfStateIsInvalidAsync(string invalidState) + { + // given + string randomCode = GetRandomString(); + + var invalidArgumentCareIdentityServiceProcessingException = + new InvalidArgumentCareIdentityServiceProcessingException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentCareIdentityServiceProcessingException.UpsertDataList( + key: "state", + value: "Text is required"); + + var expectedException = + new CareIdentityServiceProcessingValidationException( + message: "Care identity service processing validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidArgumentCareIdentityServiceProcessingException); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceProcessingService.GetUserInfoAsync(randomCode, invalidState); + + CareIdentityServiceProcessingValidationException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Theory] + [MemberData(nameof(InvalidTexts))] + public async Task ShouldThrowValidationExceptionOnGetUserInfoIfAccessTokenIsUnavailableAsync( + string invalidAccessToken) + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + + var unauthorisedCareIdentityServiceProcessingException = + new UnauthorisedCareIdentityServiceProcessingException( + message: "Authentication failed (no access token)."); + + var expectedException = + new CareIdentityServiceProcessingValidationException( + message: "Care identity service processing validation error occurred, " + + "please fix the errors and try again.", + + innerException: unauthorisedCareIdentityServiceProcessingException); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync(invalidAccessToken); + + // when + ValueTask getUserInfoTask = + this.careIdentityServiceProcessingService.GetUserInfoAsync(randomCode, randomState); + + CareIdentityServiceProcessingValidationException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.careIdentityServiceMock.Verify(service => + service.GetUserInfoAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs new file mode 100644 index 0000000..c692d61 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs @@ -0,0 +1,96 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Services.Processings.CareIdentityServices; +using Tynamix.ObjectFiller; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices +{ + public partial class CareIdentityServiceProcessingServiceTests + { + private readonly Mock careIdentityServiceMock; + private readonly ICareIdentityServiceProcessingService careIdentityServiceProcessingService; + + public CareIdentityServiceProcessingServiceTests() + { + this.careIdentityServiceMock = new Mock(); + + this.careIdentityServiceProcessingService = + new CareIdentityServiceProcessingService(this.careIdentityServiceMock.Object); + } + + public static TheoryData DependencyValidationExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceValidationException(GetRandomString(), innerException), + new CareIdentityServiceDependencyValidationException(GetRandomString(), innerException) + }; + } + + public static TheoryData DependencyExceptions() + { + var innerException = new Xeption(message: GetRandomString()); + + return new TheoryData + { + new CareIdentityServiceDependencyException(GetRandomString(), innerException), + new CareIdentityServiceServiceException(GetRandomString(), innerException) + }; + } + + public static TheoryData ServiceExceptions() => + new TheoryData + { + new Exception(), + new InvalidOperationException(), + new NotSupportedException() + }; + + public static TheoryData InvalidTexts() => + new TheoryData + { + null, + string.Empty, + " " + }; + + private static NhsUserInfo CreateRandomNhsUserInfo() => + new NhsUserInfo + { + NhsIdUserUid = GetRandomString(), + Name = GetRandomString(), + Sub = GetRandomString(), + NhsIdNrbacRoles = new List() + }; + + private static string GetRandomString() => + new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); + + private static CareIdentityServiceProcessingServiceException CreateExpectedServiceException( + Exception serviceException) + { + var failedCareIdentityServiceProcessingException = + new FailedCareIdentityServiceProcessingException( + message: "Failed care identity service processing error occurred, please contact support.", + innerException: serviceException, + data: serviceException.Data); + + return new CareIdentityServiceProcessingServiceException( + message: "Care identity service processing error occurred, please contact support.", + innerException: failedCareIdentityServiceProcessingException); + } + } +} From dc8bf92601bac42e8d005c227df2539599049149 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:03:41 +0100 Subject: [PATCH 03/17] SdkUnitTests -> PASS --- .../CareIdentityServiceClient.cs | 48 +++++++- .../PersonalDemographicsServiceClient.cs | 14 ++- ...dCareIdentityServiceDependencyException.cs | 16 +++ .../FailedPdsServiceDependencyException.cs | 16 +++ .../NullSearchCriteriaPdsServiceException.cs | 15 +++ ...SearchCriteriaPdsOrchestrationException.cs | 15 +++ .../CareIdentityService.Exceptions.cs | 63 ++++++++++- .../CareIdentityService.cs | 10 +- .../Foundations/Pds/PdsService.Exceptions.cs | 39 ++++++- .../Foundations/Pds/PdsService.Validations.cs | 46 ++++---- .../Services/Foundations/Pds/PdsService.cs | 104 +++++++++--------- .../Pds/PdsOrchestrationService.Exceptions.cs | 8 ++ .../PdsOrchestrationService.Validations.cs | 52 ++++----- .../Pds/PdsOrchestrationService.cs | 33 +++--- ...tityServiceProcessingService.Exceptions.cs | 79 ++++++++++++- .../CareIdentityServiceProcessingService.cs | 10 +- 16 files changed, 432 insertions(+), 136 deletions(-) create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/FailedCareIdentityServiceDependencyException.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/FailedPdsServiceDependencyException.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/NullSearchCriteriaPdsServiceException.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/NullSearchCriteriaPdsOrchestrationException.cs diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs index e40a863..87e331f 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs @@ -26,6 +26,10 @@ public async ValueTask BuildLoginUrlAsync(CancellationToken cancellation { return await this.careIdentityServiceProcessingService.BuildLoginUrlAsync(cancellationToken); } + catch (OperationCanceledException) + { + throw; + } catch (CareIdentityServiceProcessingValidationException careIdentityServiceProcessingValidationException) { throw CreateCareIdentityServiceClientValidationException( @@ -34,7 +38,7 @@ public async ValueTask BuildLoginUrlAsync(CancellationToken cancellation catch (CareIdentityServiceProcessingDependencyValidationException careIdentityServiceProcessingDependencyValidationException) { - throw CreateCareIdentityServiceClientValidationException( + throw CreateCareIdentityServiceClientDependencyValidationException( careIdentityServiceProcessingDependencyValidationException.InnerException as Xeption); } catch (CareIdentityServiceProcessingDependencyException careIdentityServiceProcessingDependencyException) @@ -63,6 +67,10 @@ public async ValueTask LogoutAsync(CancellationToken cancellationToken = default { await this.careIdentityServiceProcessingService.LogoutAsync(cancellationToken); } + catch (OperationCanceledException) + { + throw; + } catch (CareIdentityServiceProcessingValidationException careIdentityServiceProcessingValidationException) { throw CreateCareIdentityServiceClientValidationException( @@ -71,7 +79,7 @@ public async ValueTask LogoutAsync(CancellationToken cancellationToken = default catch (CareIdentityServiceProcessingDependencyValidationException careIdentityServiceProcessingDependencyValidationException) { - throw CreateCareIdentityServiceClientValidationException( + throw CreateCareIdentityServiceClientDependencyValidationException( careIdentityServiceProcessingDependencyValidationException.InnerException as Xeption); } catch (CareIdentityServiceProcessingDependencyException careIdentityServiceProcessingDependencyException) @@ -100,6 +108,10 @@ public async ValueTask GetAccessTokenAsync(CancellationToken cancellatio { return await this.careIdentityServiceProcessingService.GetAccessTokenAsync(cancellationToken); } + catch (OperationCanceledException) + { + throw; + } catch (CareIdentityServiceProcessingValidationException careIdentityServiceProcessingValidationException) { throw CreateCareIdentityServiceClientValidationException( @@ -108,7 +120,7 @@ public async ValueTask GetAccessTokenAsync(CancellationToken cancellatio catch (CareIdentityServiceProcessingDependencyValidationException careIdentityServiceProcessingDependencyValidationException) { - throw CreateCareIdentityServiceClientValidationException( + throw CreateCareIdentityServiceClientDependencyValidationException( careIdentityServiceProcessingDependencyValidationException.InnerException as Xeption); } catch (CareIdentityServiceProcessingDependencyException careIdentityServiceProcessingDependencyException) @@ -121,6 +133,14 @@ public async ValueTask GetAccessTokenAsync(CancellationToken cancellatio throw CreateCareIdentityServiceClientServiceException( careIdentityServiceProcessingServiceException.InnerException as Xeption); } + catch (Exception exception) + { + throw CreateCareIdentityServiceClientServiceException( + new FailedCareIdentityServiceClientException( + message: "Unexpected error occurred, contact support.", + innerException: exception, + data: exception.Data)); + } } public async ValueTask GetUserInfoAsync( @@ -132,6 +152,10 @@ public async ValueTask GetUserInfoAsync( { return await this.careIdentityServiceProcessingService.GetUserInfoAsync(code, state, cancellationToken); } + catch (OperationCanceledException) + { + throw; + } catch (CareIdentityServiceProcessingValidationException careIdentityServiceProcessingValidationException) { throw CreateCareIdentityServiceClientValidationException( @@ -140,7 +164,7 @@ public async ValueTask GetUserInfoAsync( catch (CareIdentityServiceProcessingDependencyValidationException careIdentityServiceProcessingDependencyValidationException) { - throw CreateCareIdentityServiceClientValidationException( + throw CreateCareIdentityServiceClientDependencyValidationException( careIdentityServiceProcessingDependencyValidationException.InnerException as Xeption); } catch (CareIdentityServiceProcessingDependencyException careIdentityServiceProcessingDependencyException) @@ -153,6 +177,14 @@ public async ValueTask GetUserInfoAsync( throw CreateCareIdentityServiceClientServiceException( careIdentityServiceProcessingServiceException.InnerException as Xeption); } + catch (Exception exception) + { + throw CreateCareIdentityServiceClientServiceException( + new FailedCareIdentityServiceClientException( + message: "Unexpected error occurred, contact support.", + innerException: exception, + data: exception.Data)); + } } private static CareIdentityServiceClientValidationException @@ -163,6 +195,14 @@ private static CareIdentityServiceClientValidationException innerException); } + private static CareIdentityServiceClientDependencyValidationException + CreateCareIdentityServiceClientDependencyValidationException(Xeption innerException) + { + return new CareIdentityServiceClientDependencyValidationException( + message: "Care identity service client validation error occurred, fix errors and try again.", + innerException); + } + private static CareIdentityServiceClientDependencyException CreateCareIdentityServiceClientDependencyException(Xeption innerException) { diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs index 9b18562..b741471 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs @@ -32,6 +32,10 @@ public async ValueTask SearchPatientsAsync( searchCriteria, cancellationToken); } + catch (OperationCanceledException) + { + throw; + } catch (PdsOrchestrationValidationException pdsOrchestrationValidationException) { throw CreatePersonalDemographicsServiceClientValidationException( @@ -40,7 +44,7 @@ public async ValueTask SearchPatientsAsync( catch (PdsOrchestrationDependencyValidationException pdsOrchestrationDependencyValidationException) { - throw CreatePersonalDemographicsServiceClientValidationException( + throw CreatePersonalDemographicsServiceClientDependencyValidationException( pdsOrchestrationDependencyValidationException.InnerException as Xeption); } catch (PdsOrchestrationDependencyException pdsOrchestrationDependencyException) @@ -71,6 +75,14 @@ private static PersonalDemographicsServiceClientValidationException innerException); } + private static PersonalDemographicsServiceClientDependencyValidationException + CreatePersonalDemographicsServiceClientDependencyValidationException(Xeption innerException) + { + return new PersonalDemographicsServiceClientDependencyValidationException( + message: "Personal demographics service client validation error occurred, fix errors and try again.", + innerException); + } + private static PersonalDemographicsServiceClientDependencyException CreatePersonalDemographicsServiceClientDependencyException(Xeption innerException) { diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/FailedCareIdentityServiceDependencyException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/FailedCareIdentityServiceDependencyException.cs new file mode 100644 index 0000000..9046438 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/FailedCareIdentityServiceDependencyException.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions +{ + public class FailedCareIdentityServiceDependencyException : Xeption + { + public FailedCareIdentityServiceDependencyException(string message, Exception innerException) + : base(message, innerException) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/FailedPdsServiceDependencyException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/FailedPdsServiceDependencyException.cs new file mode 100644 index 0000000..1418cb0 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/FailedPdsServiceDependencyException.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions +{ + public class FailedPdsServiceDependencyException : Xeption + { + public FailedPdsServiceDependencyException(string message, Exception innerException) + : base(message, innerException) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/NullSearchCriteriaPdsServiceException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/NullSearchCriteriaPdsServiceException.cs new file mode 100644 index 0000000..af71475 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/NullSearchCriteriaPdsServiceException.cs @@ -0,0 +1,15 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions +{ + public class NullSearchCriteriaPdsServiceException : Xeption + { + public NullSearchCriteriaPdsServiceException(string message) + : base(message) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/NullSearchCriteriaPdsOrchestrationException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/NullSearchCriteriaPdsOrchestrationException.cs new file mode 100644 index 0000000..8b8a17e --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/NullSearchCriteriaPdsOrchestrationException.cs @@ -0,0 +1,15 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions +{ + public class NullSearchCriteriaPdsOrchestrationException : Xeption + { + public NullSearchCriteriaPdsOrchestrationException(string message) + : base(message) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs index c4c8991..3f72e22 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs @@ -3,6 +3,7 @@ // --------------------------------------------------------- using System; +using System.Net.Http; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using Xeptions; @@ -28,15 +29,37 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun { throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceException); } + catch (OperationCanceledException) + { + throw; + } + catch (TimeoutException timeoutException) + { + var failedCareIdentityServiceDependencyException = + new FailedCareIdentityServiceDependencyException( + message: "Failed care identity service dependency error occurred, please contact support.", + innerException: timeoutException); + + throw await CreateDependencyExceptionAsync(failedCareIdentityServiceDependencyException); + } + catch (HttpRequestException httpRequestException) + { + var failedCareIdentityServiceDependencyException = + new FailedCareIdentityServiceDependencyException( + message: "Failed care identity service dependency error occurred, please contact support.", + innerException: httpRequestException); + + throw await CreateDependencyExceptionAsync(failedCareIdentityServiceDependencyException); + } catch (Exception exception) { - var failedPatientServiceException = + var failedCareIdentityServiceException = new FailedCareIdentityServiceException( message: "Failed care identity service error occurred, please contact support.", innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedPatientServiceException); + throw await CreateServiceExceptionAsync(failedCareIdentityServiceException); } } @@ -54,15 +77,37 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti { throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceException); } + catch (OperationCanceledException) + { + throw; + } + catch (TimeoutException timeoutException) + { + var failedCareIdentityServiceDependencyException = + new FailedCareIdentityServiceDependencyException( + message: "Failed care identity service dependency error occurred, please contact support.", + innerException: timeoutException); + + throw await CreateDependencyExceptionAsync(failedCareIdentityServiceDependencyException); + } + catch (HttpRequestException httpRequestException) + { + var failedCareIdentityServiceDependencyException = + new FailedCareIdentityServiceDependencyException( + message: "Failed care identity service dependency error occurred, please contact support.", + innerException: httpRequestException); + + throw await CreateDependencyExceptionAsync(failedCareIdentityServiceDependencyException); + } catch (Exception exception) { - var failedPatientServiceException = + var failedCareIdentityServiceException = new FailedCareIdentityServiceException( message: "Failed care identity service error occurred, please contact support.", innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedPatientServiceException); + throw await CreateServiceExceptionAsync(failedCareIdentityServiceException); } } @@ -76,6 +121,16 @@ private async ValueTask CreateValidation return careIdentityServiceValidationException; } + private async ValueTask CreateDependencyExceptionAsync( + Xeption exception) + { + var careIdentityServiceDependencyException = new CareIdentityServiceDependencyException( + message: "Care identity service dependency error occurred, please contact support.", + innerException: exception); + + return careIdentityServiceDependencyException; + } + private async ValueTask CreateServiceExceptionAsync( Xeption exception) { diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs index 8f14f6d..37c5544 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; @@ -45,6 +45,7 @@ public CareIdentityService( public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); string csrfState = this.cryptoBroker.CreateUrlSafeState(); await this.stateBroker.StoreCsrfStateAsync(csrfState, cancellationToken); CareIdentityConfigurations careIdentityConfigurations = this.configurations.CareIdentity; @@ -68,6 +69,7 @@ public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken public ValueTask LogoutAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); await this.stateBroker.ClearCsrfStateAsync(cancellationToken); await this.tokenBroker.ClearAccessTokenAsync(cancellationToken); await this.tokenBroker.ClearRefreshTokenAsync(cancellationToken); @@ -80,6 +82,7 @@ public ValueTask CallbackAsync( CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); ValidateOnCallback(code, state); string? expectedState = await this.stateBroker.GetCsrfStateAsync(cancellationToken); @@ -124,6 +127,8 @@ await this.tokenBroker.StoreActiveRoleAsync( public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); + var (accessToken, accessExpiresAtUtc) = await this.tokenBroker.GetAccessTokenAsync(cancellationToken); @@ -176,6 +181,7 @@ await this.tokenBroker.StoreRefreshTokenAsync( public ValueTask GetUserInfoAsync(string accessToken, CancellationToken cancellationToken) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); ValidateOnGetUserInfo(accessToken); CareIdentityConfigurations careIdentityConfigurations = this.configurations.CareIdentity; @@ -196,6 +202,7 @@ public ValueTask GetUserInfoAsync(string accessToken, CancellationT private ValueTask ExchangeCodeForTokenAsync(string code, CancellationToken cancellationToken) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); ValidateOnExchangeCodeForToken(code); CareIdentityConfigurations careIdentityConfigurations = this.configurations.CareIdentity; @@ -224,6 +231,7 @@ private ValueTask ExchangeRefreshTokenForTokenAsync( CancellationToken cancellationToken) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); ValidateOnExchangeRefreshTokenForToken(refreshToken); CareIdentityConfigurations careIdentityConfigurations = this.configurations.CareIdentity; diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs index 7dc0fc3..391247f 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs @@ -1,8 +1,9 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; +using System.Net.Http; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; using Xeptions; @@ -19,11 +20,36 @@ private async ValueTask TryCatch(ReturningStringFunction returningString { return await returningStringFunction(); } + catch (NullSearchCriteriaPdsServiceException nullSearchCriteriaPdsServiceException) + { + throw await CreateValidationExceptionAsync(nullSearchCriteriaPdsServiceException); + } catch (InvalidArgumentPdsServiceException invalidArgumentPdsServiceException) { throw await CreateValidationExceptionAsync(invalidArgumentPdsServiceException); } - //TODO: Extend this to catch dependency and dependency validation exceptions. + catch (OperationCanceledException) + { + throw; + } + catch (TimeoutException timeoutException) + { + var failedPdsServiceDependencyException = + new FailedPdsServiceDependencyException( + message: "Failed PDS service dependency error occurred, please contact support.", + innerException: timeoutException); + + throw await CreateDependencyExceptionAsync(failedPdsServiceDependencyException); + } + catch (HttpRequestException httpRequestException) + { + var failedPdsServiceDependencyException = + new FailedPdsServiceDependencyException( + message: "Failed PDS service dependency error occurred, please contact support.", + innerException: httpRequestException); + + throw await CreateDependencyExceptionAsync(failedPdsServiceDependencyException); + } catch (Exception exception) { var failedPdsServiceException = @@ -46,6 +72,15 @@ private async ValueTask CreateValidationException return pdsServiceValidationException; } + private async ValueTask CreateDependencyExceptionAsync(Xeption exception) + { + var pdsServiceDependencyException = new PdsServiceDependencyException( + message: "PDS service dependency error occurred, please contact support.", + innerException: exception); + + return pdsServiceDependencyException; + } + private async ValueTask CreateServiceExceptionAsync(Xeption exception) { var pdsServiceException = new PdsServiceException( diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs index c65f558..788b113 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs @@ -1,10 +1,9 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; -using System.Collections.Generic; -using System.Linq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; using Xeptions; @@ -12,41 +11,40 @@ namespace NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds { internal partial class PdsService : IPdsService { - public void ValidateOnSearchPatientsAsync( - string accessToken, - string family, - IEnumerable given, - string gender, - DateOnly? birthdate) + public void ValidateOnSearchPatients(string accessToken, SearchCriteria searchCriteria) { + ValidateSearchCriteriaIsNotNull(searchCriteria); + Validate( createException: () => new InvalidArgumentPdsServiceException( message: "Invalid argument(s), please correct the errors and try again."), - (Rule: IsInvalid(family), Parameter: nameof(family)), - (Rule: IsInvalid(given), Parameter: nameof(given)), - (Rule: IsInvalid(gender), Parameter: nameof(gender)), - (Rule: IsInvalid(birthdate), Parameter: nameof(birthdate))); + (Rule: IsInvalid(accessToken), Parameter: nameof(accessToken)), + (Rule: IsInvalidSearchCriteria(searchCriteria), Parameter: nameof(searchCriteria))); } - private static dynamic IsInvalid(string? text) => new + private static void ValidateSearchCriteriaIsNotNull(SearchCriteria searchCriteria) { - Condition = string.IsNullOrWhiteSpace(text), - Message = "Text is required" - }; + if (searchCriteria is null) + { + throw new NullSearchCriteriaPdsServiceException( + message: "Search criteria is null."); + } + } - private static dynamic IsInvalid(DateOnly? dateOnly) => new + private static dynamic IsInvalid(string text) => new { - Condition = dateOnly == null, - Message = "Date is required" + Condition = string.IsNullOrWhiteSpace(text), + Message = "Text is required" }; - private static dynamic IsInvalid(IEnumerable textList) => new + private static dynamic IsInvalidSearchCriteria(SearchCriteria searchCriteria) => new { - Condition = textList != null && - textList.Any(text => string.IsNullOrWhiteSpace(text)), + Condition = + string.IsNullOrWhiteSpace(searchCriteria.NhsNumber) && + string.IsNullOrWhiteSpace(searchCriteria.Surname), - Message = "List contains null or whitespace values" + Message = "Either an NHS number or a surname is required" }; private static void Validate( diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs index 2d12d3d..f272dbb 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -33,64 +33,66 @@ public PdsService( this.tokenBroker = tokenBroker; } - public ValueTask SearchPatientsAsync( - string accessToken, - SearchCriteria searchCriteria, - CancellationToken cancellationToken = default) => - TryCatch(async () => - { - string baseUrl = this.configurations.PersonalDemographicsService.BaseUrl.TrimEnd('/'); - string url; + public ValueTask SearchPatientsAsync( + string accessToken, + SearchCriteria searchCriteria, + CancellationToken cancellationToken = default) => + TryCatch(async () => + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateOnSearchPatients(accessToken, searchCriteria); + string baseUrl = this.configurations.PersonalDemographicsService.BaseUrl.TrimEnd('/'); + string url; - if (!string.IsNullOrWhiteSpace(searchCriteria.NhsNumber)) - { - url = $"{baseUrl}/Patient/{searchCriteria.NhsNumber}"; - } - else - { - url = $"{baseUrl}/Patient?family={Uri.EscapeDataString(searchCriteria.Surname)}"; + if (!string.IsNullOrWhiteSpace(searchCriteria.NhsNumber)) + { + url = $"{baseUrl}/Patient/{searchCriteria.NhsNumber}"; + } + else + { + url = $"{baseUrl}/Patient?family={Uri.EscapeDataString(searchCriteria.Surname)}"; - if (!string.IsNullOrWhiteSpace(searchCriteria.FirstName)) - { - url += $"&given={Uri.EscapeDataString(searchCriteria.FirstName)}"; - } + if (!string.IsNullOrWhiteSpace(searchCriteria.FirstName)) + { + url += $"&given={Uri.EscapeDataString(searchCriteria.FirstName)}"; + } - if (!string.IsNullOrWhiteSpace(searchCriteria.Gender)) - { - url += $"&gender={Uri.EscapeDataString(searchCriteria.Gender)}"; - } + if (!string.IsNullOrWhiteSpace(searchCriteria.Gender)) + { + url += $"&gender={Uri.EscapeDataString(searchCriteria.Gender)}"; + } - if (!string.IsNullOrWhiteSpace(searchCriteria.DateOfBirth)) - { - url += $"&birthdate=eq{searchCriteria.DateOfBirth:yyyy-MM-dd}"; - } + if (!string.IsNullOrWhiteSpace(searchCriteria.DateOfBirth)) + { + url += $"&birthdate=eq{searchCriteria.DateOfBirth:yyyy-MM-dd}"; + } - if (!string.IsNullOrWhiteSpace(searchCriteria.Postcode)) - { - url += $"&address-postalcode={Uri.EscapeDataString(searchCriteria.Postcode)}"; - } - } + if (!string.IsNullOrWhiteSpace(searchCriteria.Postcode)) + { + url += $"&address-postalcode={Uri.EscapeDataString(searchCriteria.Postcode)}"; + } + } - string? activeRoleId = await this.tokenBroker.GetActiveRoleAsync(cancellationToken); + string activeRoleId = await this.tokenBroker.GetActiveRoleAsync(cancellationToken); - var response = await this.httpBroker.GetAsync( - url, - request => - { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - request.Headers.Add("X-Request-ID", this.identifierBroker.GetNewGuid().ToString()); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json")); + var response = await this.httpBroker.GetAsync( + url, + request => + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Add("X-Request-ID", this.identifierBroker.GetNewGuid().ToString()); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json")); - if (!string.IsNullOrWhiteSpace(activeRoleId)) - { - request.Headers.Add("NHSD-Session-URID", activeRoleId); - } - }, - cancellationToken); + if (!string.IsNullOrWhiteSpace(activeRoleId)) + { + request.Headers.Add("NHSD-Session-URID", activeRoleId); + } + }, + cancellationToken); - response.EnsureSuccessStatusCode(); + response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync(cancellationToken); - }); - } + return await response.Content.ReadAsStringAsync(cancellationToken); + }); + } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs index 82f19a5..3bdeede 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs @@ -20,6 +20,10 @@ private async ValueTask TryCatch(ReturningStringFunction returningString { return await returningStringFunction(); } + catch (NullSearchCriteriaPdsOrchestrationException nullSearchCriteriaPdsOrchestrationException) + { + throw await CreateValidationExceptionAsync(nullSearchCriteriaPdsOrchestrationException); + } catch (InvalidArgumentPdsOrchestrationException invalidArgumentPdsOrchestrationException) { throw await CreateValidationExceptionAsync(invalidArgumentPdsOrchestrationException); @@ -28,6 +32,10 @@ private async ValueTask TryCatch(ReturningStringFunction returningString { throw await CreateValidationExceptionAsync(unauthorizedPdsOrchestrationException); } + catch (OperationCanceledException) + { + throw; + } catch (CareIdentityServiceValidationException careIdentityValidationException) { throw await CreateDependencyValidationExceptionAsync(careIdentityValidationException); diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Validations.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Validations.cs index dbdd8f0..a30b9f5 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Validations.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Validations.cs @@ -1,50 +1,52 @@ // --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- + using System; -using System.Collections.Generic; -using System.Linq; -using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; using Xeptions; namespace NHSDigital.ApiPlatform.Sdk.Services.Orchestrations.Pds { internal sealed partial class PdsOrchestrationService { - public void ValidateOnSearchPatientsAsync( - string family, - IEnumerable given, - string gender, - DateOnly? birthdate) + public void ValidateOnSearchPatients(SearchCriteria searchCriteria) { + ValidateSearchCriteriaIsNotNull(searchCriteria); + Validate( - createException: () => new InvalidArgumentCareIdentityServiceException( + createException: () => new InvalidArgumentPdsOrchestrationException( message: "Invalid argument(s), please correct the errors and try again."), - (Rule: IsInvalid(family), Parameter: nameof(family)), - (Rule: IsInvalid(given), Parameter: nameof(given)), - (Rule: IsInvalid(gender), Parameter: nameof(gender)), - (Rule: IsInvalid(birthdate), Parameter: nameof(birthdate))); + (Rule: IsInvalidSearchCriteria(searchCriteria), Parameter: nameof(searchCriteria))); } - private static dynamic IsInvalid(string? text) => new + public void ValidateAccessToken(string accessToken) { - Condition = string.IsNullOrWhiteSpace(text), - Message = "Text is required" - }; + if (string.IsNullOrWhiteSpace(accessToken)) + { + throw new UnauthorizedPdsOrchestrationException( + message: "Unauthorized - Unable to retrieve access token."); + } + } - private static dynamic IsInvalid(DateOnly? dateOnly) => new + private static void ValidateSearchCriteriaIsNotNull(SearchCriteria searchCriteria) { - Condition = dateOnly == null, - Message = "Date is required" - }; + if (searchCriteria is null) + { + throw new NullSearchCriteriaPdsOrchestrationException( + message: "Search criteria is null."); + } + } - private static dynamic IsInvalid(IEnumerable textList) => new + private static dynamic IsInvalidSearchCriteria(SearchCriteria searchCriteria) => new { - Condition = textList != null && - textList.Any(text => string.IsNullOrWhiteSpace(text)), + Condition = + string.IsNullOrWhiteSpace(searchCriteria.NhsNumber) && + string.IsNullOrWhiteSpace(searchCriteria.Surname), - Message = "List contains null or whitespace values" + Message = "Either an NHS number or a surname is required" }; private static void Validate( diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs index 4f008ad..2833e59 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs @@ -1,14 +1,9 @@ // --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- -using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; -using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Patients; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; -using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; using NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds; @@ -18,29 +13,27 @@ internal sealed partial class PdsOrchestrationService : IPdsOrchestrationService { private readonly ICareIdentityService careIdentityService; private readonly IPdsService pdsService; - private readonly IApiPlatformTokenBroker tokenBroker; - public PdsOrchestrationService(ICareIdentityService careIdentityService, IPdsService pdsService, IApiPlatformTokenBroker tokenBroker) + public PdsOrchestrationService( + ICareIdentityService careIdentityService, + IPdsService pdsService) { this.careIdentityService = careIdentityService; this.pdsService = pdsService; - this.tokenBroker = tokenBroker; } public ValueTask SearchPatientsAsync( - SearchCriteria searchCriteria, + SearchCriteria searchCriteria, CancellationToken cancellationToken = default) => - TryCatch(async () => - { - string accessToken = await this.careIdentityService.GetAccessTokenAsync(cancellationToken); - - if (string.IsNullOrWhiteSpace(accessToken)) - { - throw new UnauthorizedPdsOrchestrationException("Unauthorized - Unable to retrieve access token."); - } + TryCatch(async () => + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateOnSearchPatients(searchCriteria); + string accessToken = await this.careIdentityService.GetAccessTokenAsync(cancellationToken); + ValidateAccessToken(accessToken); - return await this.pdsService - .SearchPatientsAsync(accessToken, searchCriteria, cancellationToken); - }); + return await this.pdsService + .SearchPatientsAsync(accessToken, searchCriteria, cancellationToken); + }); } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs index d2f384c..e90c62d 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs @@ -1,9 +1,10 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; using System.Threading.Tasks; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; using Xeptions; @@ -30,6 +31,27 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun { throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceProcessingException); } + catch (OperationCanceledException) + { + throw; + } + catch (CareIdentityServiceValidationException careIdentityServiceValidationException) + { + throw await CreateDependencyValidationExceptionAsync(careIdentityServiceValidationException); + } + catch (CareIdentityServiceDependencyValidationException + careIdentityServiceDependencyValidationException) + { + throw await CreateDependencyValidationExceptionAsync(careIdentityServiceDependencyValidationException); + } + catch (CareIdentityServiceDependencyException careIdentityServiceDependencyException) + { + throw await CreateDependencyExceptionAsync(careIdentityServiceDependencyException); + } + catch (CareIdentityServiceServiceException careIdentityServiceServiceException) + { + throw await CreateDependencyExceptionAsync(careIdentityServiceServiceException); + } catch (Exception exception) { var failedCareIdentityServiceProcessingException = @@ -48,6 +70,37 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti { await returningNothingFunction(); } + catch (InvalidArgumentCareIdentityServiceProcessingException + invalidArgumentCareIdentityServiceProcessingException) + { + throw await CreateValidationExceptionAsync(invalidArgumentCareIdentityServiceProcessingException); + } + catch (UnauthorisedCareIdentityServiceProcessingException + unauthorisedCareIdentityServiceProcessingException) + { + throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceProcessingException); + } + catch (OperationCanceledException) + { + throw; + } + catch (CareIdentityServiceValidationException careIdentityServiceValidationException) + { + throw await CreateDependencyValidationExceptionAsync(careIdentityServiceValidationException); + } + catch (CareIdentityServiceDependencyValidationException + careIdentityServiceDependencyValidationException) + { + throw await CreateDependencyValidationExceptionAsync(careIdentityServiceDependencyValidationException); + } + catch (CareIdentityServiceDependencyException careIdentityServiceDependencyException) + { + throw await CreateDependencyExceptionAsync(careIdentityServiceDependencyException); + } + catch (CareIdentityServiceServiceException careIdentityServiceServiceException) + { + throw await CreateDependencyExceptionAsync(careIdentityServiceServiceException); + } catch (Exception exception) { var failedCareIdentityServiceProcessingException = @@ -72,6 +125,30 @@ private async ValueTask Create return careIdentityServiceProcessingValidationException; } + private async ValueTask + CreateDependencyValidationExceptionAsync(Xeption exception) + { + var careIdentityServiceProcessingDependencyValidationException = + new CareIdentityServiceProcessingDependencyValidationException( + message: "Care identity service processing dependency validation error occurred, " + + "please fix the errors and try again.", + + innerException: exception.InnerException as Xeption); + + return careIdentityServiceProcessingDependencyValidationException; + } + + private async ValueTask CreateDependencyExceptionAsync( + Xeption exception) + { + var careIdentityServiceProcessingDependencyException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: exception.InnerException as Xeption); + + return careIdentityServiceProcessingDependencyException; + } + private async ValueTask CreateServiceExceptionAsync( Xeption exception) { diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs index 7f939a0..49f3c2f 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -19,19 +19,23 @@ public CareIdentityServiceProcessingService(ICareIdentityService careIdentitySer public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); + return await this.careIdentityService.BuildLoginUrlAsync(cancellationToken); }); - public ValueTask LogoutAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); await this.careIdentityService.LogoutAsync(cancellationToken); }); public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); + return await this.careIdentityService.GetAccessTokenAsync(cancellationToken); }); @@ -41,6 +45,7 @@ public ValueTask GetUserInfoAsync( CancellationToken cancellationToken = default) => TryCatch(async () => { + cancellationToken.ThrowIfCancellationRequested(); ValidateOnGetUserInfo(code, state); await this.careIdentityService.CallbackAsync(code, state, cancellationToken); string accessToken = await this.careIdentityService.GetAccessTokenAsync(cancellationToken); @@ -51,6 +56,5 @@ public ValueTask GetUserInfoAsync( return userInfo; }); - } } From 0c8f2fd53fe75efbb480e1dc390f00e585f03429 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:08:27 +0100 Subject: [PATCH 04/17] CareIdentityServiceStateValidationTests -> FAIL --- .../CareIdentityServiceTests.Exceptions.cs | 24 ------ .../CareIdentityServiceTests.Validations.cs | 73 +++++++++++++++++++ 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs index 5de6b68..d1227c0 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs @@ -231,30 +231,6 @@ await Assert.ThrowsAsync( .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); } - [Fact] - public async Task ShouldThrowServiceExceptionOnCallbackIfStateDoesNotMatchAsync() - { - // given - string randomCode = GetRandomString(); - string randomState = GetRandomString(); - string differentState = GetRandomString(); - - this.stateBrokerMock.Setup(broker => - broker.GetCsrfStateAsync(It.IsAny())) - .ReturnsAsync(differentState); - - // when - ValueTask callbackTask = this.careIdentityService.CallbackAsync(randomCode, randomState); - - CareIdentityServiceServiceException actualCareIdentityServiceServiceException = - await Assert.ThrowsAsync( - async () => await callbackTask); - - // then - actualCareIdentityServiceServiceException.InnerException.InnerException - .Should().BeOfType(); - } - private static CareIdentityServiceDependencyException CreateExpectedDependencyException( Exception dependencyException) { diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs index 1b70e35..9f6701c 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs @@ -127,6 +127,79 @@ await Assert.ThrowsAsync( Times.Never); } + [Fact] + public async Task ShouldThrowValidationExceptionOnCallbackIfStateDoesNotMatchStoredStateAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + string differentState = GetRandomString(); + + var invalidStateCareIdentityServiceException = + new InvalidStateCareIdentityServiceException( + message: "Invalid state parameter."); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidStateCareIdentityServiceException); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .ReturnsAsync(differentState); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync(randomCode, randomState); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.stateBrokerMock.Verify(broker => + broker.ClearCsrfStateAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldThrowValidationExceptionOnCallbackIfNoStateWasStoredAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + + var invalidStateCareIdentityServiceException = + new InvalidStateCareIdentityServiceException( + message: "Invalid state parameter."); + + var expectedCareIdentityServiceValidationException = + new CareIdentityServiceValidationException( + message: "Care identity service validation error occurred, " + + "please fix the errors and try again.", + + innerException: invalidStateCareIdentityServiceException); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .ReturnsAsync((string)null); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync(randomCode, randomState); + + CareIdentityServiceValidationException actualCareIdentityServiceValidationException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceValidationException + .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + } + [Fact] public async Task ShouldThrowValidationExceptionOnGetAccessTokenIfRefreshedTokenIsEmptyAsync() { From a06c748ef6d9b3077e651df5eef9147e48d10cea Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:08:27 +0100 Subject: [PATCH 05/17] CareIdentityServiceStateValidationTests -> PASS --- ...nvalidStateCareIdentityServiceException.cs | 15 +++++++ .../CareIdentityService.Exceptions.cs | 42 ++++++++++++++++++- .../CareIdentityService.Validations.cs | 10 +++++ .../CareIdentityService.cs | 8 +--- 4 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidStateCareIdentityServiceException.cs diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidStateCareIdentityServiceException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidStateCareIdentityServiceException.cs new file mode 100644 index 0000000..5d93471 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidStateCareIdentityServiceException.cs @@ -0,0 +1,15 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions +{ + public class InvalidStateCareIdentityServiceException : Xeption + { + public InvalidStateCareIdentityServiceException(string message) + : base(message) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs index 3f72e22..42da6f9 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -29,6 +29,26 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun { throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceException); } + catch (InvalidStateCareIdentityServiceException invalidStateCareIdentityServiceException) + { + throw await CreateValidationExceptionAsync(invalidStateCareIdentityServiceException); + } + catch (CareIdentityServiceValidationException) + { + throw; + } + catch (CareIdentityServiceDependencyValidationException) + { + throw; + } + catch (CareIdentityServiceDependencyException) + { + throw; + } + catch (CareIdentityServiceServiceException) + { + throw; + } catch (OperationCanceledException) { throw; @@ -77,6 +97,26 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti { throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceException); } + catch (InvalidStateCareIdentityServiceException invalidStateCareIdentityServiceException) + { + throw await CreateValidationExceptionAsync(invalidStateCareIdentityServiceException); + } + catch (CareIdentityServiceValidationException) + { + throw; + } + catch (CareIdentityServiceDependencyValidationException) + { + throw; + } + catch (CareIdentityServiceDependencyException) + { + throw; + } + catch (CareIdentityServiceServiceException) + { + throw; + } catch (OperationCanceledException) { throw; diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Validations.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Validations.cs index 2460f20..85dec74 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Validations.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Validations.cs @@ -28,6 +28,16 @@ public void ValidateOnExchangeCodeForToken(string code) (Rule: IsInvalid(code), Parameter: nameof(code))); } + public void ValidateStateMatches(string state, string expectedState) + { + if (string.IsNullOrWhiteSpace(expectedState) || + string.Equals(state, expectedState, StringComparison.Ordinal) is false) + { + throw new InvalidStateCareIdentityServiceException( + message: "Invalid state parameter."); + } + } + public void ValidateAccessToken(string accessToken) { if (string.IsNullOrWhiteSpace(accessToken)) diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs index 37c5544..a54840e 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs @@ -86,13 +86,7 @@ public ValueTask CallbackAsync( ValidateOnCallback(code, state); string? expectedState = await this.stateBroker.GetCsrfStateAsync(cancellationToken); - - if (string.IsNullOrWhiteSpace(expectedState) || - string.Equals(state, expectedState, StringComparison.Ordinal) is false) - { - throw new InvalidOperationException("Invalid state parameter."); - } - + ValidateStateMatches(state, expectedState); await this.stateBroker.ClearCsrfStateAsync(cancellationToken); TokenResult token = await ExchangeCodeForTokenAsync(code, cancellationToken); From d53f8ece23417a9f0ecb10b62c3bb87f87077dd7 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:23:10 +0100 Subject: [PATCH 06/17] SdkTimeoutAndCancellationTests -> FAIL --- ...dentityServiceClientTests.Cancellations.cs | 99 ++++++++- ...lientTests.Cancellations.SearchPatients.cs | 67 +++++- .../CareIdentityServiceTests.Cancellations.cs | 118 ++++++----- .../CareIdentityServiceTests.Exceptions.cs | 46 ++++- .../CareIdentityServiceTests.Timeouts.cs | 192 ++++++++++++++++++ .../CareIdentityServiceTests.Validations.cs | 26 ++- .../CareIdentityServiceTests.cs | 13 +- ...rviceTests.Cancellations.SearchPatients.cs | 46 ++--- ...sServiceTests.Exceptions.SearchPatients.cs | 10 +- ...PdsServiceTests.Timeouts.SearchPatients.cs | 107 ++++++++++ ...ServiceTests.Validations.SearchPatients.cs | 14 +- .../Foundations/Pds/PdsServiceTests.cs | 24 ++- ...rviceTests.Cancellations.SearchPatients.cs | 72 ++++++- ...nServiceTests.Exceptions.SearchPatients.cs | 14 +- ...ServiceTests.Validations.SearchPatients.cs | 14 +- .../Pds/PdsOrchestrationServiceTests.cs | 12 +- ...iceProcessingServiceTests.Cancellations.cs | 159 +++++++++++---- ...erviceProcessingServiceTests.Exceptions.cs | 22 +- ...rviceProcessingServiceTests.Validations.cs | 14 +- ...reIdentityServiceProcessingServiceTests.cs | 13 +- 20 files changed, 912 insertions(+), 170 deletions(-) create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs index 666f8b1..b5d8169 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs @@ -5,7 +5,9 @@ using System; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; using Xunit; @@ -14,12 +16,63 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices public partial class CareIdentityServiceClientTests { [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnBuildLoginUrlAsync() + public async Task ShouldThrowClientDependencyExceptionOnBuildLoginUrlIfOperationCanceledExceptionOccursAsync() { // given + var operationCanceledException = new OperationCanceledException(); + + CareIdentityServiceClientDependencyException expectedException = + CreateExpectedTimeoutDependencyException(); + this.careIdentityServiceProcessingServiceMock.Setup(service => service.BuildLoginUrlAsync(It.IsAny())) - .ThrowsAsync(new OperationCanceledException()); + .ThrowsAsync(operationCanceledException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); + + CareIdentityServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Fact] + public async Task ShouldThrowClientDependencyExceptionOnGetAccessTokenIfTaskCanceledExceptionOccursAsync() + { + // given + var taskCanceledException = new TaskCanceledException(); + + CareIdentityServiceClientDependencyException expectedException = + CreateExpectedTimeoutDependencyException(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(taskCanceledException); + + // when + ValueTask getAccessTokenTask = this.careIdentityServiceClient.GetAccessTokenAsync(); + + CareIdentityServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await getAccessTokenTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + } + + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionOnBuildLoginUrlIfCancellationRequestedAsync() + { + // given + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); @@ -29,12 +82,15 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnBuildLoginUrlAsync() } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnLogoutAsync() + public async Task ShouldNotWrapOperationCanceledExceptionOnLogoutIfCancellationRequestedAsync() { // given + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + this.careIdentityServiceProcessingServiceMock.Setup(service => service.LogoutAsync(It.IsAny())) - .ThrowsAsync(new OperationCanceledException()); + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when ValueTask logoutTask = this.careIdentityServiceClient.LogoutAsync(); @@ -44,18 +100,27 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnLogoutAsync() } [Fact] - public async Task ShouldNotWrapTaskCanceledExceptionOnGetAccessTokenAsync() + public async Task ShouldNotWrapOperationCanceledExceptionOnGetUserInfoIfCancellationRequestedAsync() { // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + this.careIdentityServiceProcessingServiceMock.Setup(service => - service.GetAccessTokenAsync(It.IsAny())) - .ThrowsAsync(new TaskCanceledException()); + service.GetUserInfoAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when - ValueTask getAccessTokenTask = this.careIdentityServiceClient.GetAccessTokenAsync(); + ValueTask getUserInfoTask = + this.careIdentityServiceClient.GetUserInfoAsync(randomCode, randomState); // then - await Assert.ThrowsAnyAsync(async () => await getAccessTokenTask); + await Assert.ThrowsAsync(async () => await getUserInfoTask); } [Fact] @@ -82,5 +147,21 @@ public async Task ShouldPropagateCancellationTokenToProcessingServiceOnGetUserIn service.GetUserInfoAsync(randomCode, randomState, cancellationToken), Times.Once); } + + private static CareIdentityServiceClientDependencyException CreateExpectedTimeoutDependencyException() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceClientException = + new TimeoutCareIdentityServiceClientException( + message: "Failed care identity service client timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return new CareIdentityServiceClientDependencyException( + message: "Care identity service client dependency error occurred, contact support.", + innerException: timeoutCareIdentityServiceClientException); + } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs index 775c16b..f47d97f 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs @@ -5,7 +5,9 @@ using System; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; using Xunit; @@ -14,39 +16,75 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServ public partial class PersonalDemographicsServiceClientTests { [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsAsync() + public async Task ShouldThrowClientDependencyExceptionOnSearchPatientsIfOperationCanceledExceptionOccursAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + var operationCanceledException = new OperationCanceledException(); + + PersonalDemographicsServiceClientDependencyException expectedException = + CreateExpectedTimeoutDependencyException(); this.pdsOrchestrationServiceMock.Setup(service => service.SearchPatientsAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new OperationCanceledException()); + .ThrowsAsync(operationCanceledException); // when ValueTask searchPatientsTask = this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + PersonalDemographicsServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + // then - await Assert.ThrowsAsync(async () => await searchPatientsTask); + actualException.Should().BeEquivalentTo(expectedException); } [Fact] - public async Task ShouldNotWrapTaskCanceledExceptionOnSearchPatientsAsync() + public async Task ShouldThrowClientDependencyExceptionOnSearchPatientsIfTaskCanceledExceptionOccursAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + var taskCanceledException = new TaskCanceledException(); + + PersonalDemographicsServiceClientDependencyException expectedException = + CreateExpectedTimeoutDependencyException(); this.pdsOrchestrationServiceMock.Setup(service => service.SearchPatientsAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new TaskCanceledException()); + .ThrowsAsync(taskCanceledException); // when ValueTask searchPatientsTask = this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + PersonalDemographicsServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + // then - await Assert.ThrowsAnyAsync(async () => await searchPatientsTask); + actualException.Should().BeEquivalentTo(expectedException); + } + + [Fact] + public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsIfCancellationRequestedAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + this.pdsOrchestrationServiceMock.Setup(service => + service.SearchPatientsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask searchPatientsTask = + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + // then + await Assert.ThrowsAsync(async () => await searchPatientsTask); } [Fact] @@ -71,5 +109,22 @@ await this.personalDemographicsServiceClient.SearchPatientsAsync( service.SearchPatientsAsync(randomSearchCriteria, cancellationToken), Times.Once); } + + private static PersonalDemographicsServiceClientDependencyException + CreateExpectedTimeoutDependencyException() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutPersonalDemographicsServiceClientException = + new TimeoutPersonalDemographicsServiceClientException( + message: "Failed personal demographics service client timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return new PersonalDemographicsServiceClientDependencyException( + message: "Personal demographics service client dependency error occurred, contact support.", + innerException: timeoutPersonalDemographicsServiceClientException); + } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs index cd653b3..9f602b1 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs @@ -15,115 +15,141 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentit public partial class CareIdentityServiceTests { [Fact] - public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfTokenIsAlreadyCancelledAsync() + public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfCancellationRequestedAsync() { // given - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); + var cancellationToken = new CancellationToken(canceled: true); // when ValueTask buildLoginUrlTask = - this.careIdentityService.BuildLoginUrlAsync(cancellationTokenSource.Token); + this.careIdentityService.BuildLoginUrlAsync(cancellationToken); // then - await Assert.ThrowsAnyAsync(async () => await buildLoginUrlTask); + await Assert.ThrowsAsync(async () => await buildLoginUrlTask); this.cryptoBrokerMock.Verify(broker => broker.CreateUrlSafeState(It.IsAny()), Times.Never); + + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnBuildLoginUrlAsync() + public async Task ShouldThrowOperationCanceledExceptionOnLogoutIfCancellationRequestedAsync() { // given - this.cryptoBrokerMock.Setup(broker => - broker.CreateUrlSafeState(It.IsAny())) - .Throws(new OperationCanceledException()); + var cancellationToken = new CancellationToken(canceled: true); // when - ValueTask buildLoginUrlTask = this.careIdentityService.BuildLoginUrlAsync(); + ValueTask logoutTask = this.careIdentityService.LogoutAsync(cancellationToken); // then - await Assert.ThrowsAsync(async () => await buildLoginUrlTask); + await Assert.ThrowsAsync(async () => await logoutTask); + + this.stateBrokerMock.VerifyNoOtherCalls(); + this.tokenBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnLogoutAsync() + public async Task ShouldThrowOperationCanceledExceptionOnCallbackIfCancellationRequestedAsync() { // given - this.stateBrokerMock.Setup(broker => - broker.ClearCsrfStateAsync(It.IsAny())) - .Throws(new OperationCanceledException()); + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + var cancellationToken = new CancellationToken(canceled: true); // when - ValueTask logoutTask = this.careIdentityService.LogoutAsync(); + ValueTask callbackTask = this.careIdentityService.CallbackAsync( + randomCode, + randomState, + cancellationToken); // then - await Assert.ThrowsAsync(async () => await logoutTask); + await Assert.ThrowsAsync(async () => await callbackTask); + + this.stateBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldNotWrapTaskCanceledExceptionOnGetUserInfoAsync() + public async Task ShouldThrowOperationCanceledExceptionOnGetAccessTokenIfCancellationRequestedAsync() { // given - string randomAccessToken = GetRandomString(); + var cancellationToken = new CancellationToken(canceled: true); - this.httpBrokerMock.Setup(broker => - broker.GetAsync( - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Throws(new TaskCanceledException()); + // when + ValueTask getAccessTokenTask = + this.careIdentityService.GetAccessTokenAsync(cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await getAccessTokenTask); + + this.tokenBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnGetUserInfoIfCancellationRequestedAsync() + { + // given + string randomAccessToken = GetRandomString(); + var cancellationToken = new CancellationToken(canceled: true); // when ValueTask getUserInfoTask = - this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + this.careIdentityService.GetUserInfoAsync(randomAccessToken, cancellationToken); // then - await Assert.ThrowsAnyAsync(async () => await getUserInfoTask); + await Assert.ThrowsAsync(async () => await getUserInfoTask); + + this.httpBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldThrowOperationCanceledExceptionOnGetAccessTokenIfTokenIsAlreadyCancelledAsync() + public async Task ShouldNotWrapOperationCanceledExceptionRaisedByABrokerOnGetUserInfoAsync() { // given + string randomAccessToken = GetRandomString(); using var cancellationTokenSource = new CancellationTokenSource(); cancellationTokenSource.Cancel(); + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(new OperationCanceledException(cancellationTokenSource.Token)); + // when - ValueTask getAccessTokenTask = - this.careIdentityService.GetAccessTokenAsync(cancellationTokenSource.Token); + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); // then - await Assert.ThrowsAnyAsync(async () => await getAccessTokenTask); + await Assert.ThrowsAsync(async () => await getUserInfoTask); - this.tokenBrokerMock.Verify(broker => - broker.GetAccessTokenAsync(It.IsAny()), - Times.Never); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldThrowOperationCanceledExceptionOnCallbackIfTokenIsAlreadyCancelledAsync() + public async Task ShouldPropagateCancellationTokenToBrokersOnLogoutAsync() { // given - string randomCode = GetRandomString(); - string randomState = GetRandomString(); using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); + CancellationToken cancellationToken = cancellationTokenSource.Token; // when - ValueTask callbackTask = this.careIdentityService.CallbackAsync( - randomCode, - randomState, - cancellationTokenSource.Token); + await this.careIdentityService.LogoutAsync(cancellationToken); // then - await Assert.ThrowsAnyAsync(async () => await callbackTask); - this.stateBrokerMock.Verify(broker => - broker.GetCsrfStateAsync(It.IsAny()), - Times.Never); + broker.ClearCsrfStateAsync(cancellationToken), + Times.Once); + + this.tokenBrokerMock.Verify(broker => + broker.ClearAccessTokenAsync(cancellationToken), + Times.Once); } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs index d1227c0..37e7032 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Exceptions.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -10,6 +10,7 @@ using Moq; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using Xeptions; using Xunit; namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices @@ -39,6 +40,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceDependencyException .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); } [Theory] @@ -64,6 +69,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceServiceException .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceServiceException))), + Times.Once); } [Theory] @@ -89,6 +98,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceDependencyException .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); } [Theory] @@ -114,6 +127,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceServiceException .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceServiceException))), + Times.Once); } [Theory] @@ -145,6 +162,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceDependencyException .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); } [Theory] @@ -176,6 +197,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceServiceException .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceServiceException))), + Times.Once); } [Theory] @@ -201,6 +226,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceDependencyException .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); } [Theory] @@ -229,19 +258,28 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceServiceException .Should().BeEquivalentTo(expectedCareIdentityServiceServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceServiceException))), + Times.Once); } private static CareIdentityServiceDependencyException CreateExpectedDependencyException( Exception dependencyException) { - var failedCareIdentityServiceDependencyException = - new FailedCareIdentityServiceDependencyException( + Xeption expectedInnerException = dependencyException is TimeoutException + ? new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: dependencyException, + data: dependencyException.Data) + + : new FailedCareIdentityServiceDependencyException( message: "Failed care identity service dependency error occurred, please contact support.", innerException: dependencyException); return new CareIdentityServiceDependencyException( message: "Care identity service dependency error occurred, please contact support.", - innerException: failedCareIdentityServiceDependencyException); + innerException: expectedInnerException); } private static CareIdentityServiceServiceException CreateExpectedServiceException(Exception serviceException) diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs new file mode 100644 index 0000000..7d28eeb --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs @@ -0,0 +1,192 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldThrowDependencyExceptionOnBuildLoginUrlIfOperationCanceledExceptionOccursAndLogItAsync() + { + // given + var operationCanceledException = new OperationCanceledException(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.cryptoBrokerMock.Setup(broker => + broker.CreateUrlSafeState(It.IsAny())) + .Throws(operationCanceledException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityService.BuildLoginUrlAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnLogoutIfOperationCanceledExceptionOccursAndLogItAsync() + { + // given + var operationCanceledException = new OperationCanceledException(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.stateBrokerMock.Setup(broker => + broker.ClearCsrfStateAsync(It.IsAny())) + .Throws(operationCanceledException); + + // when + ValueTask logoutTask = this.careIdentityService.LogoutAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfTaskCanceledExceptionOccursAndLogItAsync() + { + // given + string randomAccessToken = GetRandomString(); + var taskCanceledException = new TaskCanceledException(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(taskCanceledException); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnGetAccessTokenIfOperationCanceledExceptionOccursAndLogItAsync() + { + // given + var operationCanceledException = new OperationCanceledException(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .Throws(operationCanceledException); + + // when + ValueTask getAccessTokenTask = this.careIdentityService.GetAccessTokenAsync(); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await getAccessTokenTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnCallbackIfOperationCanceledExceptionOccursAndLogItAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + var operationCanceledException = new OperationCanceledException(); + + CareIdentityServiceDependencyException expectedCareIdentityServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.stateBrokerMock.Setup(broker => + broker.GetCsrfStateAsync(It.IsAny())) + .Throws(operationCanceledException); + + // when + ValueTask callbackTask = this.careIdentityService.CallbackAsync(randomCode, randomState); + + CareIdentityServiceDependencyException actualCareIdentityServiceDependencyException = + await Assert.ThrowsAsync( + async () => await callbackTask); + + // then + actualCareIdentityServiceDependencyException + .Should().BeEquivalentTo(expectedCareIdentityServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedCareIdentityServiceDependencyException))), + Times.Once); + } + + private static CareIdentityServiceDependencyException CreateExpectedTimeoutDependencyException() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return new CareIdentityServiceDependencyException( + message: "Care identity service dependency error occurred, please contact support.", + innerException: timeoutCareIdentityServiceException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs index 9f6701c..bc8265a 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Validations.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -49,6 +49,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceValidationException .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); } [Theory] @@ -84,6 +88,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceValidationException .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); } [Theory] @@ -119,6 +127,10 @@ await Assert.ThrowsAsync( actualCareIdentityServiceValidationException .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); + this.httpBrokerMock.Verify(broker => broker.GetAsync( It.IsAny(), @@ -161,6 +173,10 @@ await Assert.ThrowsAsync( actualCareIdentityServiceValidationException .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); + this.stateBrokerMock.Verify(broker => broker.ClearCsrfStateAsync(It.IsAny()), Times.Never); @@ -198,6 +214,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceValidationException .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); } [Fact] @@ -254,6 +274,10 @@ await Assert.ThrowsAsync( // then actualCareIdentityServiceValidationException .Should().BeEquivalentTo(expectedCareIdentityServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedCareIdentityServiceValidationException))), + Times.Once); } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs index 942d4f6..9ca1e06 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.cs @@ -1,21 +1,24 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; using System.Collections.Generic; +using System.Linq.Expressions; using System.Net; using System.Net.Http; using Moq; using NHSDigital.ApiPlatform.Sdk.Brokers.Cryptographies; using NHSDigital.ApiPlatform.Sdk.Brokers.DateTimes; using NHSDigital.ApiPlatform.Sdk.Brokers.Https; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Brokers.Serializations; using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; using NHSDigital.ApiPlatform.Sdk.Models.Configurations; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; using Tynamix.ObjectFiller; +using Xeptions; using Xunit; namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices @@ -28,6 +31,7 @@ public partial class CareIdentityServiceTests private readonly Mock dateTimeBrokerMock; private readonly Mock stateBrokerMock; private readonly Mock tokenBrokerMock; + private readonly Mock loggingBrokerMock; private readonly ApiPlatformConfigurations apiPlatformConfigurations; private readonly ICareIdentityService careIdentityService; @@ -39,6 +43,7 @@ public CareIdentityServiceTests() this.dateTimeBrokerMock = new Mock(); this.stateBrokerMock = new Mock(); this.tokenBrokerMock = new Mock(); + this.loggingBrokerMock = new Mock(); this.apiPlatformConfigurations = CreateRandomConfigurations(); this.careIdentityService = new CareIdentityService( @@ -48,7 +53,8 @@ public CareIdentityServiceTests() cryptoBroker: this.cryptoBrokerMock.Object, dateTimeBroker: this.dateTimeBrokerMock.Object, stateBroker: this.stateBrokerMock.Object, - tokenBroker: this.tokenBrokerMock.Object); + tokenBroker: this.tokenBrokerMock.Object, + loggingBroker: this.loggingBrokerMock.Object); } public static TheoryData DependencyExceptions() => @@ -94,6 +100,9 @@ private static ApiPlatformConfigurations CreateRandomConfigurations() => } }; + private static Expression> SameExceptionAs(Xeption expectedException) => + actualException => (actualException as Xeption).SameExceptionAs(expectedException); + private static string GetRandomString() => new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs index 025ff32..1e17d4b 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs @@ -15,44 +15,42 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds public partial class PdsServiceTests { [Fact] - public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfTokenIsAlreadyCancelledAsync() + public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfCancellationRequestedAsync() { // given string randomAccessToken = GetRandomString(); SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); + var cancellationToken = new CancellationToken(canceled: true); // when ValueTask searchPatientsTask = this.pdsService.SearchPatientsAsync( randomAccessToken, randomSearchCriteria, - cancellationTokenSource.Token); + cancellationToken); // then - await Assert.ThrowsAnyAsync(async () => await searchPatientsTask); + await Assert.ThrowsAsync(async () => await searchPatientsTask); - this.httpBrokerMock.Verify(broker => - broker.GetAsync( - It.IsAny(), - It.IsAny>(), - It.IsAny()), - Times.Never); + this.httpBrokerMock.VerifyNoOtherCalls(); + this.tokenBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsAsync() + public async Task ShouldNotWrapOperationCanceledExceptionRaisedByABrokerOnSearchPatientsAsync() { // given string randomAccessToken = GetRandomString(); SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); this.httpBrokerMock.Setup(broker => broker.GetAsync( It.IsAny(), It.IsAny>(), It.IsAny())) - .Throws(new OperationCanceledException()); + .Throws(new OperationCanceledException(cancellationTokenSource.Token)); // when ValueTask searchPatientsTask = @@ -60,28 +58,8 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsAsync() // then await Assert.ThrowsAsync(async () => await searchPatientsTask); - } - - [Fact] - public async Task ShouldNotWrapTaskCanceledExceptionOnSearchPatientsAsync() - { - // given - string randomAccessToken = GetRandomString(); - SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); - - this.httpBrokerMock.Setup(broker => - broker.GetAsync( - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Throws(new TaskCanceledException()); - // when - ValueTask searchPatientsTask = - this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); - - // then - await Assert.ThrowsAnyAsync(async () => await searchPatientsTask); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs index 5e0665c..4300a50 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Exceptions.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -47,6 +47,10 @@ await Assert.ThrowsAsync( // then actualPdsServiceDependencyException .Should().BeEquivalentTo(expectedPdsServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsServiceDependencyException))), + Times.Once); } [Theory] @@ -78,6 +82,10 @@ await Assert.ThrowsAsync( // then actualPdsServiceException.Should().BeEquivalentTo(expectedPdsServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsServiceException))), + Times.Once); } [Fact] diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs new file mode 100644 index 0000000..6f5b0ea --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs @@ -0,0 +1,107 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Fact] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfOperationCanceledExceptionOccursAndLogItAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + var operationCanceledException = new OperationCanceledException(); + + PdsServiceDependencyException expectedPdsServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(operationCanceledException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyException actualPdsServiceDependencyException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceDependencyException + .Should().BeEquivalentTo(expectedPdsServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedPdsServiceDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfTaskCanceledExceptionOccursAndLogItAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + var taskCanceledException = new TaskCanceledException(); + + PdsServiceDependencyException expectedPdsServiceDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(taskCanceledException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyException actualPdsServiceDependencyException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualPdsServiceDependencyException + .Should().BeEquivalentTo(expectedPdsServiceDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedPdsServiceDependencyException))), + Times.Once); + } + + private static PdsServiceDependencyException CreateExpectedTimeoutDependencyException() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutPdsServiceException = + new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return new PdsServiceDependencyException( + message: "PDS service dependency error occurred, please contact support.", + innerException: timeoutPdsServiceException); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs index d2aaea4..fff1044 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Validations.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -44,6 +44,10 @@ await Assert.ThrowsAsync( actualPdsServiceValidationException .Should().BeEquivalentTo(expectedPdsServiceValidationException); + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsServiceValidationException))), + Times.Once); + this.httpBrokerMock.Verify(broker => broker.GetAsync( It.IsAny(), @@ -84,6 +88,10 @@ await Assert.ThrowsAsync( // then actualPdsServiceValidationException .Should().BeEquivalentTo(expectedPdsServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsServiceValidationException))), + Times.Once); } [Fact] @@ -117,6 +125,10 @@ await Assert.ThrowsAsync( // then actualPdsServiceValidationException .Should().BeEquivalentTo(expectedPdsServiceValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsServiceValidationException))), + Times.Once); } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs index 3537397..1ed5ad0 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs @@ -1,19 +1,22 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; +using System.Linq.Expressions; using System.Net; using System.Net.Http; using Moq; using NHSDigital.ApiPlatform.Sdk.Brokers.Https; using NHSDigital.ApiPlatform.Sdk.Brokers.Identifiers; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; using NHSDigital.ApiPlatform.Sdk.Models.Configurations; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; using NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds; using Tynamix.ObjectFiller; +using Xeptions; using Xunit; namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds @@ -23,6 +26,7 @@ public partial class PdsServiceTests private readonly Mock httpBrokerMock; private readonly Mock identifierBrokerMock; private readonly Mock tokenBrokerMock; + private readonly Mock loggingBrokerMock; private readonly ApiPlatformConfigurations apiPlatformConfigurations; private readonly IPdsService pdsService; @@ -31,6 +35,7 @@ public PdsServiceTests() this.httpBrokerMock = new Mock(); this.identifierBrokerMock = new Mock(); this.tokenBrokerMock = new Mock(); + this.loggingBrokerMock = new Mock(); this.apiPlatformConfigurations = new ApiPlatformConfigurations { @@ -44,7 +49,8 @@ public PdsServiceTests() configurations: this.apiPlatformConfigurations, httpBroker: this.httpBrokerMock.Object, identifierBroker: this.identifierBrokerMock.Object, - tokenBroker: this.tokenBrokerMock.Object); + tokenBroker: this.tokenBrokerMock.Object, + loggingBroker: this.loggingBrokerMock.Object); } public static TheoryData DependencyExceptions() => @@ -86,6 +92,9 @@ private static SearchCriteria CreateRandomSearchCriteriaWithDemographics() => Postcode = GetRandomString() }; + private static Expression> SameExceptionAs(Xeption expectedException) => + actualException => (actualException as Xeption).SameExceptionAs(expectedException); + private static string GetRandomString() => new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); @@ -98,14 +107,19 @@ private static HttpResponseMessage CreateHttpResponse(string content) => private static PdsServiceDependencyException CreateExpectedDependencyException( Exception dependencyException) { - var failedPdsServiceDependencyException = - new FailedPdsServiceDependencyException( + Xeption expectedInnerException = dependencyException is TimeoutException + ? new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: dependencyException, + data: dependencyException.Data) + + : new FailedPdsServiceDependencyException( message: "Failed PDS service dependency error occurred, please contact support.", innerException: dependencyException); return new PdsServiceDependencyException( message: "PDS service dependency error occurred, please contact support.", - innerException: failedPdsServiceDependencyException); + innerException: expectedInnerException); } private static PdsServiceException CreateExpectedServiceException(Exception serviceException) diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs index e2a23f5..4f802c1 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs @@ -5,8 +5,10 @@ using System; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; using Moq; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; using Xunit; namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds @@ -14,35 +16,67 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds public partial class PdsOrchestrationServiceTests { [Fact] - public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfTokenIsAlreadyCancelledAsync() + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfOperationCanceledExceptionOccursAndLogItAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); + var operationCanceledException = new OperationCanceledException(); + + PdsOrchestrationDependencyException expectedPdsOrchestrationDependencyException = + CreateExpectedTimeoutDependencyException(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(operationCanceledException); + + // when + ValueTask searchPatientsTask = + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedPdsOrchestrationDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is( + SameExceptionAs(expectedPdsOrchestrationDependencyException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfCancellationRequestedAsync() + { + // given + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + var cancellationToken = new CancellationToken(canceled: true); // when ValueTask searchPatientsTask = this.pdsOrchestrationService.SearchPatientsAsync( randomSearchCriteria, - cancellationTokenSource.Token); + cancellationToken); // then - await Assert.ThrowsAnyAsync(async () => await searchPatientsTask); + await Assert.ThrowsAsync(async () => await searchPatientsTask); - this.careIdentityServiceMock.Verify(service => - service.GetAccessTokenAsync(It.IsAny()), - Times.Never); + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.pdsServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsAsync() + public async Task ShouldNotWrapOperationCanceledExceptionRaisedByADependencyOnSearchPatientsAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); this.careIdentityServiceMock.Setup(service => service.GetAccessTokenAsync(It.IsAny())) - .ThrowsAsync(new OperationCanceledException()); + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when ValueTask searchPatientsTask = @@ -50,6 +84,8 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsAsync() // then await Assert.ThrowsAsync(async () => await searchPatientsTask); + + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] @@ -84,5 +120,21 @@ public async Task ShouldPropagateCancellationTokenToDependenciesOnSearchPatients service.SearchPatientsAsync(randomAccessToken, randomSearchCriteria, cancellationToken), Times.Once); } + + private static PdsOrchestrationDependencyException CreateExpectedTimeoutDependencyException() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutPdsOrchestrationException = + new TimeoutPdsOrchestrationException( + message: "Failed PDS orchestration timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return new PdsOrchestrationDependencyException( + message: "PDS orchestration dependency error occurred, fix the errors and try again.", + innerException: timeoutPdsOrchestrationException); + } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs index 4236bcb..f803096 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -43,6 +43,10 @@ await Assert.ThrowsAsync( // then actualException.Should().BeEquivalentTo(expectedPdsOrchestrationDependencyValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationDependencyValidationException))), + Times.Once); } [Theory] @@ -72,6 +76,10 @@ await Assert.ThrowsAsync( // then actualException.Should().BeEquivalentTo(expectedPdsOrchestrationDependencyException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationDependencyException))), + Times.Once); } [Theory] @@ -100,6 +108,10 @@ await Assert.ThrowsAsync( // then actualPdsOrchestrationServiceException .Should().BeEquivalentTo(expectedPdsOrchestrationServiceException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationServiceException))), + Times.Once); } [Fact] diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs index e628c12..161c341 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Validations.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -41,6 +41,10 @@ await Assert.ThrowsAsync( actualPdsOrchestrationValidationException .Should().BeEquivalentTo(expectedPdsOrchestrationValidationException); + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationValidationException))), + Times.Once); + this.careIdentityServiceMock.Verify(service => service.GetAccessTokenAsync(It.IsAny()), Times.Never); @@ -76,6 +80,10 @@ await Assert.ThrowsAsync( // then actualPdsOrchestrationValidationException .Should().BeEquivalentTo(expectedPdsOrchestrationValidationException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationValidationException))), + Times.Once); } [Fact] @@ -109,6 +117,10 @@ await Assert.ThrowsAsync( actualPdsOrchestrationValidationException .Should().BeEquivalentTo(expectedPdsOrchestrationValidationException); + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedPdsOrchestrationValidationException))), + Times.Once); + this.pdsServiceMock.Verify(service => service.SearchPatientsAsync( It.IsAny(), diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs index eb89d8e..ddef9d6 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs @@ -1,9 +1,11 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; +using System.Linq.Expressions; using Moq; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; @@ -21,16 +23,19 @@ public partial class PdsOrchestrationServiceTests { private readonly Mock careIdentityServiceMock; private readonly Mock pdsServiceMock; + private readonly Mock loggingBrokerMock; private readonly IPdsOrchestrationService pdsOrchestrationService; public PdsOrchestrationServiceTests() { this.careIdentityServiceMock = new Mock(); this.pdsServiceMock = new Mock(); + this.loggingBrokerMock = new Mock(); this.pdsOrchestrationService = new PdsOrchestrationService( careIdentityService: this.careIdentityServiceMock.Object, - pdsService: this.pdsServiceMock.Object); + pdsService: this.pdsServiceMock.Object, + loggingBroker: this.loggingBrokerMock.Object); } public static TheoryData DependencyValidationExceptions() @@ -73,6 +78,9 @@ private static SearchCriteria CreateRandomSearchCriteria() => NhsNumber = GetRandomString() }; + private static Expression> SameExceptionAs(Xeption expectedException) => + actualException => (actualException as Xeption).SameExceptionAs(expectedException); + private static string GetRandomString() => new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs index 15dcd6d..e98cffa 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs @@ -5,8 +5,10 @@ using System; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; using Moq; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; using Xunit; namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentityServices @@ -14,77 +16,145 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentit public partial class CareIdentityServiceProcessingServiceTests { [Fact] - public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfTokenIsAlreadyCancelledAsync() + public async Task ShouldThrowDependencyExceptionOnBuildLoginUrlIfOperationCanceledExceptionOccursAndLogItAsync() { // given - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); + var operationCanceledException = new OperationCanceledException(); + + CareIdentityServiceProcessingDependencyException expectedException = + CreateExpectedTimeoutDependencyException(); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(operationCanceledException); // when ValueTask buildLoginUrlTask = - this.careIdentityServiceProcessingService.BuildLoginUrlAsync(cancellationTokenSource.Token); + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + CareIdentityServiceProcessingDependencyException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); // then - await Assert.ThrowsAnyAsync(async () => await buildLoginUrlTask); + actualException.Should().BeEquivalentTo(expectedException); - this.careIdentityServiceMock.Verify(service => - service.BuildLoginUrlAsync(It.IsAny()), - Times.Never); + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); } [Fact] - public async Task ShouldThrowOperationCanceledExceptionOnLogoutIfTokenIsAlreadyCancelledAsync() + public async Task ShouldThrowDependencyExceptionOnLogoutIfTaskCanceledExceptionOccursAndLogItAsync() { // given - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); + var taskCanceledException = new TaskCanceledException(); + + CareIdentityServiceProcessingDependencyException expectedException = + CreateExpectedTimeoutDependencyException(); + + this.careIdentityServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(taskCanceledException); + + // when + ValueTask logoutTask = this.careIdentityServiceProcessingService.LogoutAsync(); + + CareIdentityServiceProcessingDependencyException actualException = + await Assert.ThrowsAsync( + async () => await logoutTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfCancellationRequestedAsync() + { + // given + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await buildLoginUrlTask); + + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnLogoutIfCancellationRequestedAsync() + { + // given + var cancellationToken = new CancellationToken(canceled: true); // when ValueTask logoutTask = - this.careIdentityServiceProcessingService.LogoutAsync(cancellationTokenSource.Token); + this.careIdentityServiceProcessingService.LogoutAsync(cancellationToken); // then - await Assert.ThrowsAnyAsync(async () => await logoutTask); + await Assert.ThrowsAsync(async () => await logoutTask); - this.careIdentityServiceMock.Verify(service => - service.LogoutAsync(It.IsAny()), - Times.Never); + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ShouldThrowOperationCanceledExceptionOnGetAccessTokenIfCancellationRequestedAsync() + { + // given + var cancellationToken = new CancellationToken(canceled: true); + + // when + ValueTask getAccessTokenTask = + this.careIdentityServiceProcessingService.GetAccessTokenAsync(cancellationToken); + + // then + await Assert.ThrowsAsync(async () => await getAccessTokenTask); + + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldThrowOperationCanceledExceptionOnGetUserInfoIfTokenIsAlreadyCancelledAsync() + public async Task ShouldThrowOperationCanceledExceptionOnGetUserInfoIfCancellationRequestedAsync() { // given string randomCode = GetRandomString(); string randomState = GetRandomString(); - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); + var cancellationToken = new CancellationToken(canceled: true); // when ValueTask getUserInfoTask = this.careIdentityServiceProcessingService.GetUserInfoAsync( randomCode, randomState, - cancellationTokenSource.Token); + cancellationToken); // then - await Assert.ThrowsAnyAsync(async () => await getUserInfoTask); + await Assert.ThrowsAsync(async () => await getUserInfoTask); - this.careIdentityServiceMock.Verify(service => - service.CallbackAsync( - It.IsAny(), - It.IsAny(), - It.IsAny()), - Times.Never); + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnGetAccessTokenAsync() + public async Task ShouldNotWrapOperationCanceledExceptionRaisedByADependencyOnGetAccessTokenAsync() { // given + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + this.careIdentityServiceMock.Setup(service => service.GetAccessTokenAsync(It.IsAny())) - .ThrowsAsync(new OperationCanceledException()); + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when ValueTask getAccessTokenTask = @@ -92,21 +162,8 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnGetAccessTokenAsync() // then await Assert.ThrowsAsync(async () => await getAccessTokenTask); - } - [Fact] - public async Task ShouldNotWrapTaskCanceledExceptionOnLogoutAsync() - { - // given - this.careIdentityServiceMock.Setup(service => - service.LogoutAsync(It.IsAny())) - .ThrowsAsync(new TaskCanceledException()); - - // when - ValueTask logoutTask = this.careIdentityServiceProcessingService.LogoutAsync(); - - // then - await Assert.ThrowsAnyAsync(async () => await logoutTask); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] @@ -142,5 +199,21 @@ await this.careIdentityServiceProcessingService.GetUserInfoAsync( service.GetUserInfoAsync(randomAccessToken, cancellationToken), Times.Once); } + + private static CareIdentityServiceProcessingDependencyException CreateExpectedTimeoutDependencyException() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceProcessingException = + new TimeoutCareIdentityServiceProcessingException( + message: "Failed care identity service processing timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: timeoutCareIdentityServiceProcessingException); + } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs index 35c6861..9f77620 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -43,6 +43,10 @@ await Assert.ThrowsAsync + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); } [Theory] @@ -70,6 +74,10 @@ await Assert.ThrowsAsync( // then actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); } [Theory] @@ -95,6 +103,10 @@ await Assert.ThrowsAsync( // then actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); } [Theory] @@ -121,6 +133,10 @@ await Assert.ThrowsAsync( // then actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); } [Theory] @@ -144,6 +160,10 @@ await Assert.ThrowsAsync( // then actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); } [Fact] diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs index 9a82bec..306664b 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Validations.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -47,6 +47,10 @@ await Assert.ThrowsAsync( // then actualException.Should().BeEquivalentTo(expectedException); + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + this.careIdentityServiceMock.Verify(service => service.CallbackAsync( It.IsAny(), @@ -87,6 +91,10 @@ await Assert.ThrowsAsync( // then actualException.Should().BeEquivalentTo(expectedException); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); } [Theory] @@ -124,6 +132,10 @@ await Assert.ThrowsAsync( // then actualException.Should().BeEquivalentTo(expectedException); + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + this.careIdentityServiceMock.Verify(service => service.GetUserInfoAsync(It.IsAny(), It.IsAny()), Times.Never); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs index c692d61..96a3bce 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.cs @@ -1,10 +1,12 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; using System.Collections.Generic; +using System.Linq.Expressions; using Moq; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; @@ -19,14 +21,18 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentit public partial class CareIdentityServiceProcessingServiceTests { private readonly Mock careIdentityServiceMock; + private readonly Mock loggingBrokerMock; private readonly ICareIdentityServiceProcessingService careIdentityServiceProcessingService; public CareIdentityServiceProcessingServiceTests() { this.careIdentityServiceMock = new Mock(); + this.loggingBrokerMock = new Mock(); this.careIdentityServiceProcessingService = - new CareIdentityServiceProcessingService(this.careIdentityServiceMock.Object); + new CareIdentityServiceProcessingService( + careIdentityService: this.careIdentityServiceMock.Object, + loggingBroker: this.loggingBrokerMock.Object); } public static TheoryData DependencyValidationExceptions() @@ -76,6 +82,9 @@ private static NhsUserInfo CreateRandomNhsUserInfo() => NhsIdNrbacRoles = new List() }; + private static Expression> SameExceptionAs(Xeption expectedException) => + actualException => (actualException as Xeption).SameExceptionAs(expectedException); + private static string GetRandomString() => new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); From a33b8f90897331d2c4ea850f682bea36382eb7c9 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:23:11 +0100 Subject: [PATCH 07/17] SdkTimeoutAndCancellationTests -> PASS --- .../Brokers/Loggings/ILoggingBroker.cs | 15 ++ .../Brokers/Loggings/LoggingBroker.cs | 32 ++++ .../CareIdentityServiceClient.cs | 37 ++++- .../PersonalDemographicsServiceClient.cs | 21 +++ ...meoutCareIdentityServiceClientException.cs | 17 ++ ...sonalDemographicsServiceClientException.cs | 17 ++ .../TimeoutCareIdentityServiceException.cs | 17 ++ .../Exceptions/TimeoutPdsServiceException.cs | 17 ++ .../TimeoutPdsOrchestrationException.cs | 17 ++ ...tCareIdentityServiceProcessingException.cs | 17 ++ .../NHSDigital.ApiPlatform.Sdk.csproj | 1 + .../ServiceCollectionExtensions.cs | 3 + .../CareIdentityService.Exceptions.cs | 97 ++++++++---- .../CareIdentityService.cs | 6 +- .../Foundations/Pds/PdsService.Exceptions.cs | 69 +++++++-- .../Services/Foundations/Pds/PdsService.cs | 8 +- .../Pds/PdsOrchestrationService.Exceptions.cs | 114 +++++++++++--- .../Pds/PdsOrchestrationService.cs | 6 +- ...tityServiceProcessingService.Exceptions.cs | 146 +++++++++++++++--- .../CareIdentityServiceProcessingService.cs | 9 +- 20 files changed, 572 insertions(+), 94 deletions(-) create mode 100644 NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/ILoggingBroker.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/LoggingBroker.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Clients/CareIdentityService/Exceptions/TimeoutCareIdentityServiceClientException.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceException.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/TimeoutPdsServiceException.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/TimeoutPdsOrchestrationException.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Processings/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceProcessingException.cs diff --git a/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/ILoggingBroker.cs b/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/ILoggingBroker.cs new file mode 100644 index 0000000..140d409 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/ILoggingBroker.cs @@ -0,0 +1,15 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading.Tasks; + +namespace NHSDigital.ApiPlatform.Sdk.Brokers.Loggings +{ + public interface ILoggingBroker + { + ValueTask LogErrorAsync(Exception exception); + ValueTask LogCriticalAsync(Exception exception); + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/LoggingBroker.cs b/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/LoggingBroker.cs new file mode 100644 index 0000000..afda747 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Brokers/Loggings/LoggingBroker.cs @@ -0,0 +1,32 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace NHSDigital.ApiPlatform.Sdk.Brokers.Loggings +{ + internal sealed class LoggingBroker : ILoggingBroker + { + private readonly ILogger logger; + + public LoggingBroker(ILogger logger) => + this.logger = logger; + + public ValueTask LogErrorAsync(Exception exception) + { + this.logger.LogError(exception, exception.Message); + + return ValueTask.CompletedTask; + } + + public ValueTask LogCriticalAsync(Exception exception) + { + this.logger.LogCritical(exception, exception.Message); + + return ValueTask.CompletedTask; + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs index 87e331f..d340271 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -26,6 +26,11 @@ public async ValueTask BuildLoginUrlAsync(CancellationToken cancellation { return await this.careIdentityServiceProcessingService.BuildLoginUrlAsync(cancellationToken); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw CreateCareIdentityServiceClientTimeoutDependencyException(); + } catch (OperationCanceledException) { throw; @@ -67,6 +72,11 @@ public async ValueTask LogoutAsync(CancellationToken cancellationToken = default { await this.careIdentityServiceProcessingService.LogoutAsync(cancellationToken); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw CreateCareIdentityServiceClientTimeoutDependencyException(); + } catch (OperationCanceledException) { throw; @@ -108,6 +118,11 @@ public async ValueTask GetAccessTokenAsync(CancellationToken cancellatio { return await this.careIdentityServiceProcessingService.GetAccessTokenAsync(cancellationToken); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw CreateCareIdentityServiceClientTimeoutDependencyException(); + } catch (OperationCanceledException) { throw; @@ -152,6 +167,11 @@ public async ValueTask GetUserInfoAsync( { return await this.careIdentityServiceProcessingService.GetUserInfoAsync(code, state, cancellationToken); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw CreateCareIdentityServiceClientTimeoutDependencyException(); + } catch (OperationCanceledException) { throw; @@ -187,6 +207,21 @@ public async ValueTask GetUserInfoAsync( } } + private static CareIdentityServiceClientDependencyException + CreateCareIdentityServiceClientTimeoutDependencyException() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceClientException = + new TimeoutCareIdentityServiceClientException( + message: "Failed care identity service client timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return CreateCareIdentityServiceClientDependencyException(timeoutCareIdentityServiceClientException); + } + private static CareIdentityServiceClientValidationException CreateCareIdentityServiceClientValidationException(Xeption innerException) { diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs index b741471..9256874 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs @@ -32,6 +32,11 @@ public async ValueTask SearchPatientsAsync( searchCriteria, cancellationToken); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw CreatePersonalDemographicsServiceClientTimeoutDependencyException(); + } catch (OperationCanceledException) { throw; @@ -67,6 +72,22 @@ public async ValueTask SearchPatientsAsync( } } + private static PersonalDemographicsServiceClientDependencyException + CreatePersonalDemographicsServiceClientTimeoutDependencyException() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutPersonalDemographicsServiceClientException = + new TimeoutPersonalDemographicsServiceClientException( + message: "Failed personal demographics service client timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return CreatePersonalDemographicsServiceClientDependencyException( + timeoutPersonalDemographicsServiceClientException); + } + private static PersonalDemographicsServiceClientValidationException CreatePersonalDemographicsServiceClientValidationException(Xeption innerException) { diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Clients/CareIdentityService/Exceptions/TimeoutCareIdentityServiceClientException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Clients/CareIdentityService/Exceptions/TimeoutCareIdentityServiceClientException.cs new file mode 100644 index 0000000..1b55aaa --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Clients/CareIdentityService/Exceptions/TimeoutCareIdentityServiceClientException.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions +{ + public class TimeoutCareIdentityServiceClientException : Xeption + { + public TimeoutCareIdentityServiceClientException(string message, Exception innerException, IDictionary data) + : base(message, innerException, data) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs new file mode 100644 index 0000000..3bcc494 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions +{ + public class TimeoutPersonalDemographicsServiceClientException : Xeption + { + public TimeoutPersonalDemographicsServiceClientException(string message, Exception innerException, IDictionary data) + : base(message, innerException, data) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceException.cs new file mode 100644 index 0000000..185d390 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceException.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions +{ + public class TimeoutCareIdentityServiceException : Xeption + { + public TimeoutCareIdentityServiceException(string message, Exception innerException, IDictionary data) + : base(message, innerException, data) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/TimeoutPdsServiceException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/TimeoutPdsServiceException.cs new file mode 100644 index 0000000..515993b --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/TimeoutPdsServiceException.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions +{ + public class TimeoutPdsServiceException : Xeption + { + public TimeoutPdsServiceException(string message, Exception innerException, IDictionary data) + : base(message, innerException, data) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/TimeoutPdsOrchestrationException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/TimeoutPdsOrchestrationException.cs new file mode 100644 index 0000000..a25e819 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/TimeoutPdsOrchestrationException.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions +{ + public class TimeoutPdsOrchestrationException : Xeption + { + public TimeoutPdsOrchestrationException(string message, Exception innerException, IDictionary data) + : base(message, innerException, data) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Processings/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceProcessingException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Processings/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceProcessingException.cs new file mode 100644 index 0000000..c296424 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Processings/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceProcessingException.cs @@ -0,0 +1,17 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions +{ + public class TimeoutCareIdentityServiceProcessingException : Xeption + { + public TimeoutCareIdentityServiceProcessingException(string message, Exception innerException, IDictionary data) + : base(message, innerException, data) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj b/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj index 8ab16f3..711cdf9 100644 --- a/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj +++ b/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj @@ -59,6 +59,7 @@ + diff --git a/NHSDigital.ApiPlatform.Sdk/ServiceCollectionExtensions.cs b/NHSDigital.ApiPlatform.Sdk/ServiceCollectionExtensions.cs index 56e281f..c41ddd8 100644 --- a/NHSDigital.ApiPlatform.Sdk/ServiceCollectionExtensions.cs +++ b/NHSDigital.ApiPlatform.Sdk/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using NHSDigital.ApiPlatform.Sdk.Brokers.DateTimes; using NHSDigital.ApiPlatform.Sdk.Brokers.Https; using NHSDigital.ApiPlatform.Sdk.Brokers.Identifiers; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Brokers.Serializations; using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; using NHSDigital.ApiPlatform.Sdk.Clients.ApiPlatforms; @@ -33,6 +34,8 @@ public static IServiceCollection AddApiPlatformSdkCore( services.AddSingleton(); services.AddSingleton(); services.AddHttpClient("NhsApiPlatform"); + services.AddLogging(); + services.TryAddSingleton(); services.AddTransient(); services.AddScoped(); services.AddScoped(); diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs index 42da6f9..048a87f 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -21,17 +21,26 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun { return await returningTaskFunction(); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw await CreateAndLogTimeoutDependencyExceptionAsync(); + } + catch (OperationCanceledException) + { + throw; + } catch (InvalidArgumentCareIdentityServiceException invalidArgumentCareIdentityServiceException) { - throw await CreateValidationExceptionAsync(invalidArgumentCareIdentityServiceException); + throw await CreateAndLogValidationExceptionAsync(invalidArgumentCareIdentityServiceException); } catch (UnauthorisedCareIdentityServiceException unauthorisedCareIdentityServiceException) { - throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceException); + throw await CreateAndLogValidationExceptionAsync(unauthorisedCareIdentityServiceException); } catch (InvalidStateCareIdentityServiceException invalidStateCareIdentityServiceException) { - throw await CreateValidationExceptionAsync(invalidStateCareIdentityServiceException); + throw await CreateAndLogValidationExceptionAsync(invalidStateCareIdentityServiceException); } catch (CareIdentityServiceValidationException) { @@ -49,18 +58,15 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun { throw; } - catch (OperationCanceledException) - { - throw; - } catch (TimeoutException timeoutException) { - var failedCareIdentityServiceDependencyException = - new FailedCareIdentityServiceDependencyException( - message: "Failed care identity service dependency error occurred, please contact support.", - innerException: timeoutException); + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); - throw await CreateDependencyExceptionAsync(failedCareIdentityServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(timeoutCareIdentityServiceException); } catch (HttpRequestException httpRequestException) { @@ -69,7 +75,7 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun message: "Failed care identity service dependency error occurred, please contact support.", innerException: httpRequestException); - throw await CreateDependencyExceptionAsync(failedCareIdentityServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(failedCareIdentityServiceDependencyException); } catch (Exception exception) { @@ -79,7 +85,7 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedCareIdentityServiceException); + throw await CreateAndLogServiceExceptionAsync(failedCareIdentityServiceException); } } @@ -89,17 +95,26 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti { await returningNothingFunction(); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw await CreateAndLogTimeoutDependencyExceptionAsync(); + } + catch (OperationCanceledException) + { + throw; + } catch (InvalidArgumentCareIdentityServiceException invalidArgumentCareIdentityServiceException) { - throw await CreateValidationExceptionAsync(invalidArgumentCareIdentityServiceException); + throw await CreateAndLogValidationExceptionAsync(invalidArgumentCareIdentityServiceException); } catch (UnauthorisedCareIdentityServiceException unauthorisedCareIdentityServiceException) { - throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceException); + throw await CreateAndLogValidationExceptionAsync(unauthorisedCareIdentityServiceException); } catch (InvalidStateCareIdentityServiceException invalidStateCareIdentityServiceException) { - throw await CreateValidationExceptionAsync(invalidStateCareIdentityServiceException); + throw await CreateAndLogValidationExceptionAsync(invalidStateCareIdentityServiceException); } catch (CareIdentityServiceValidationException) { @@ -117,18 +132,15 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti { throw; } - catch (OperationCanceledException) - { - throw; - } catch (TimeoutException timeoutException) { - var failedCareIdentityServiceDependencyException = - new FailedCareIdentityServiceDependencyException( - message: "Failed care identity service dependency error occurred, please contact support.", - innerException: timeoutException); + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); - throw await CreateDependencyExceptionAsync(failedCareIdentityServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(timeoutCareIdentityServiceException); } catch (HttpRequestException httpRequestException) { @@ -137,7 +149,7 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti message: "Failed care identity service dependency error occurred, please contact support.", innerException: httpRequestException); - throw await CreateDependencyExceptionAsync(failedCareIdentityServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(failedCareIdentityServiceDependencyException); } catch (Exception exception) { @@ -147,37 +159,58 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedCareIdentityServiceException); + throw await CreateAndLogServiceExceptionAsync(failedCareIdentityServiceException); } } - private async ValueTask CreateValidationExceptionAsync( + private async ValueTask + CreateAndLogTimeoutDependencyExceptionAsync() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return await CreateAndLogDependencyExceptionAsync(timeoutCareIdentityServiceException); + } + + private async ValueTask CreateAndLogValidationExceptionAsync( Xeption exception) { var careIdentityServiceValidationException = new CareIdentityServiceValidationException( message: "Care identity service validation error occurred, please fix the errors and try again.", innerException: exception); + await this.loggingBroker.LogErrorAsync(careIdentityServiceValidationException); + return careIdentityServiceValidationException; } - private async ValueTask CreateDependencyExceptionAsync( + private async ValueTask CreateAndLogDependencyExceptionAsync( Xeption exception) { var careIdentityServiceDependencyException = new CareIdentityServiceDependencyException( message: "Care identity service dependency error occurred, please contact support.", innerException: exception); + await this.loggingBroker.LogErrorAsync(careIdentityServiceDependencyException); + return careIdentityServiceDependencyException; } - private async ValueTask CreateServiceExceptionAsync( + private async ValueTask CreateAndLogServiceExceptionAsync( Xeption exception) { var careIdentityServiceServiceException = new CareIdentityServiceServiceException( message: "Care identity service error occurred, please contact support.", innerException: exception); + await this.loggingBroker.LogErrorAsync(careIdentityServiceServiceException); + return careIdentityServiceServiceException; } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs index a54840e..39b87db 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs @@ -8,6 +8,7 @@ using NHSDigital.ApiPlatform.Sdk.Brokers.Cryptographies; using NHSDigital.ApiPlatform.Sdk.Brokers.DateTimes; using NHSDigital.ApiPlatform.Sdk.Brokers.Https; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Brokers.Serializations; using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; using NHSDigital.ApiPlatform.Sdk.Models.Configurations; @@ -24,6 +25,7 @@ internal sealed partial class CareIdentityService : ICareIdentityService private readonly IDateTimeBroker dateTimeBroker; private readonly IApiPlatformStateBroker stateBroker; private readonly IApiPlatformTokenBroker tokenBroker; + private readonly ILoggingBroker loggingBroker; public CareIdentityService( ApiPlatformConfigurations configurations, @@ -32,8 +34,10 @@ public CareIdentityService( ICryptoBroker cryptoBroker, IDateTimeBroker dateTimeBroker, IApiPlatformStateBroker stateBroker, - IApiPlatformTokenBroker tokenBroker) + IApiPlatformTokenBroker tokenBroker, + ILoggingBroker loggingBroker) { + this.loggingBroker = loggingBroker; this.configurations = configurations; this.httpBroker = httpBroker; this.jsonBroker = jsonBroker; diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs index 391247f..d5ae219 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs @@ -20,26 +20,48 @@ private async ValueTask TryCatch(ReturningStringFunction returningString { return await returningStringFunction(); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw await CreateAndLogTimeoutDependencyExceptionAsync(); + } + catch (OperationCanceledException) + { + throw; + } catch (NullSearchCriteriaPdsServiceException nullSearchCriteriaPdsServiceException) { - throw await CreateValidationExceptionAsync(nullSearchCriteriaPdsServiceException); + throw await CreateAndLogValidationExceptionAsync(nullSearchCriteriaPdsServiceException); } catch (InvalidArgumentPdsServiceException invalidArgumentPdsServiceException) { - throw await CreateValidationExceptionAsync(invalidArgumentPdsServiceException); + throw await CreateAndLogValidationExceptionAsync(invalidArgumentPdsServiceException); } - catch (OperationCanceledException) + catch (PdsServiceValidationException) + { + throw; + } + catch (PdsServiceDependencyValidationException) + { + throw; + } + catch (PdsServiceDependencyException) + { + throw; + } + catch (PdsServiceException) { throw; } catch (TimeoutException timeoutException) { - var failedPdsServiceDependencyException = - new FailedPdsServiceDependencyException( - message: "Failed PDS service dependency error occurred, please contact support.", - innerException: timeoutException); + var timeoutPdsServiceException = + new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); - throw await CreateDependencyExceptionAsync(failedPdsServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(timeoutPdsServiceException); } catch (HttpRequestException httpRequestException) { @@ -48,7 +70,7 @@ private async ValueTask TryCatch(ReturningStringFunction returningString message: "Failed PDS service dependency error occurred, please contact support.", innerException: httpRequestException); - throw await CreateDependencyExceptionAsync(failedPdsServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(failedPdsServiceDependencyException); } catch (Exception exception) { @@ -58,35 +80,56 @@ private async ValueTask TryCatch(ReturningStringFunction returningString innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedPdsServiceException); + throw await CreateAndLogServiceExceptionAsync(failedPdsServiceException); } } - private async ValueTask CreateValidationExceptionAsync( + private async ValueTask CreateAndLogTimeoutDependencyExceptionAsync() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutPdsServiceException = + new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return await CreateAndLogDependencyExceptionAsync(timeoutPdsServiceException); + } + + private async ValueTask CreateAndLogValidationExceptionAsync( Xeption exception) { var pdsServiceValidationException = new PdsServiceValidationException( message: "PDS service validation error occurred, please fix the errors and try again.", innerException: exception); + await this.loggingBroker.LogErrorAsync(pdsServiceValidationException); + return pdsServiceValidationException; } - private async ValueTask CreateDependencyExceptionAsync(Xeption exception) + private async ValueTask CreateAndLogDependencyExceptionAsync( + Xeption exception) { var pdsServiceDependencyException = new PdsServiceDependencyException( message: "PDS service dependency error occurred, please contact support.", innerException: exception); + await this.loggingBroker.LogErrorAsync(pdsServiceDependencyException); + return pdsServiceDependencyException; } - private async ValueTask CreateServiceExceptionAsync(Xeption exception) + private async ValueTask CreateAndLogServiceExceptionAsync(Xeption exception) { var pdsServiceException = new PdsServiceException( message: "PDS service error occurred, please contact support.", innerException: exception); + await this.loggingBroker.LogErrorAsync(pdsServiceException); + return pdsServiceException; } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs index f272dbb..10d1020 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -8,6 +8,7 @@ using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Brokers.Https; using NHSDigital.ApiPlatform.Sdk.Brokers.Identifiers; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Brokers.Storages; using NHSDigital.ApiPlatform.Sdk.Models.Configurations; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; @@ -20,13 +21,16 @@ internal partial class PdsService : IPdsService private readonly IHttpBroker httpBroker; private readonly IIdentifierBroker identifierBroker; private readonly IApiPlatformTokenBroker tokenBroker; + private readonly ILoggingBroker loggingBroker; public PdsService( ApiPlatformConfigurations configurations, IHttpBroker httpBroker, IIdentifierBroker identifierBroker, - IApiPlatformTokenBroker tokenBroker) + IApiPlatformTokenBroker tokenBroker, + ILoggingBroker loggingBroker) { + this.loggingBroker = loggingBroker; this.configurations = configurations; this.httpBroker = httpBroker; this.identifierBroker = identifierBroker; diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs index 3bdeede..3c94a1e 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs @@ -1,6 +1,7 @@ // --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- + using System; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; @@ -20,53 +21,84 @@ private async ValueTask TryCatch(ReturningStringFunction returningString { return await returningStringFunction(); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw await CreateAndLogTimeoutDependencyExceptionAsync(); + } + catch (OperationCanceledException) + { + throw; + } catch (NullSearchCriteriaPdsOrchestrationException nullSearchCriteriaPdsOrchestrationException) { - throw await CreateValidationExceptionAsync(nullSearchCriteriaPdsOrchestrationException); + throw await CreateAndLogValidationExceptionAsync(nullSearchCriteriaPdsOrchestrationException); } catch (InvalidArgumentPdsOrchestrationException invalidArgumentPdsOrchestrationException) { - throw await CreateValidationExceptionAsync(invalidArgumentPdsOrchestrationException); + throw await CreateAndLogValidationExceptionAsync(invalidArgumentPdsOrchestrationException); } catch (UnauthorizedPdsOrchestrationException unauthorizedPdsOrchestrationException) { - throw await CreateValidationExceptionAsync(unauthorizedPdsOrchestrationException); + throw await CreateAndLogValidationExceptionAsync(unauthorizedPdsOrchestrationException); } - catch (OperationCanceledException) + catch (PdsOrchestrationValidationException) + { + throw; + } + catch (PdsOrchestrationDependencyValidationException) + { + throw; + } + catch (PdsOrchestrationDependencyException) + { + throw; + } + catch (PdsOrchestrationServiceException) { throw; } catch (CareIdentityServiceValidationException careIdentityValidationException) { - throw await CreateDependencyValidationExceptionAsync(careIdentityValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync(careIdentityValidationException); } catch (CareIdentityServiceDependencyValidationException careIdentityDependencyValidationException) { - throw await CreateDependencyValidationExceptionAsync(careIdentityDependencyValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync(careIdentityDependencyValidationException); } catch (CareIdentityServiceDependencyException careIdentityServiceDependencyException) { - throw await CreateDependencyExceptionAsync(careIdentityServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceDependencyException); } catch (CareIdentityServiceServiceException careIdentityServiceServiceException) { - throw await CreateDependencyExceptionAsync(careIdentityServiceServiceException); + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceServiceException); } - catch (PdsServiceValidationException pdsIdentityValidationException) + catch (PdsServiceValidationException pdsServiceValidationException) { - throw await CreateDependencyValidationExceptionAsync(pdsIdentityValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync(pdsServiceValidationException); } - catch (PdsServiceDependencyValidationException pdsIdentityDependencyValidationException) + catch (PdsServiceDependencyValidationException pdsServiceDependencyValidationException) { - throw await CreateDependencyValidationExceptionAsync(pdsIdentityDependencyValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync(pdsServiceDependencyValidationException); } - catch (PdsServiceDependencyException pdsIdentityServiceDependencyException) + catch (PdsServiceDependencyException pdsServiceDependencyException) { - throw await CreateDependencyExceptionAsync(pdsIdentityServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(pdsServiceDependencyException); } - catch (PdsServiceException pdsIdentityServiceServiceException) + catch (PdsServiceException pdsServiceException) { - throw await CreateDependencyExceptionAsync(pdsIdentityServiceServiceException); + throw await CreateAndLogDependencyExceptionAsync(pdsServiceException); + } + catch (TimeoutException timeoutException) + { + var timeoutPdsOrchestrationException = + new TimeoutPdsOrchestrationException( + message: "Failed PDS orchestration timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + throw await CreateAndLogTimeoutExceptionAsync(timeoutPdsOrchestrationException); } catch (Exception exception) { @@ -76,47 +108,85 @@ private async ValueTask TryCatch(ReturningStringFunction returningString innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedPdsOrchestrationException); + throw await CreateAndLogServiceExceptionAsync(failedPdsOrchestrationException); } } - private async ValueTask CreateValidationExceptionAsync(Xeption exception) + private async ValueTask CreateAndLogTimeoutDependencyExceptionAsync() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutPdsOrchestrationException = + new TimeoutPdsOrchestrationException( + message: "Failed PDS orchestration timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return await CreateAndLogTimeoutExceptionAsync(timeoutPdsOrchestrationException); + } + + private async ValueTask CreateAndLogTimeoutExceptionAsync( + Xeption exception) + { + var pdsOrchestrationDependencyException = + new PdsOrchestrationDependencyException( + message: "PDS orchestration dependency error occurred, fix the errors and try again.", + innerException: exception); + + await this.loggingBroker.LogErrorAsync(pdsOrchestrationDependencyException); + + return pdsOrchestrationDependencyException; + } + + private async ValueTask CreateAndLogValidationExceptionAsync( + Xeption exception) { var pdsOrchestrationValidationException = new PdsOrchestrationValidationException( message: "PDS orchestration validation error occurred, fix the errors and try again.", innerException: exception); + await this.loggingBroker.LogErrorAsync(pdsOrchestrationValidationException); + return pdsOrchestrationValidationException; } - private async ValueTask CreateDependencyValidationExceptionAsync( - Xeption exception) + private async ValueTask + CreateAndLogDependencyValidationExceptionAsync(Xeption exception) { var pdsOrchestrationDependencyValidationException = new PdsOrchestrationDependencyValidationException( message: "PDS orchestration dependency validation error occurred, fix the errors and try again.", innerException: exception.InnerException as Xeption); + await this.loggingBroker.LogErrorAsync(pdsOrchestrationDependencyValidationException); + return pdsOrchestrationDependencyValidationException; } - private async ValueTask CreateDependencyExceptionAsync(Xeption exception) + private async ValueTask CreateAndLogDependencyExceptionAsync( + Xeption exception) { var pdsOrchestrationDependencyException = new PdsOrchestrationDependencyException( message: "PDS orchestration dependency error occurred, fix the errors and try again.", innerException: exception.InnerException as Xeption); + await this.loggingBroker.LogErrorAsync(pdsOrchestrationDependencyException); + return pdsOrchestrationDependencyException; } - private async ValueTask CreateServiceExceptionAsync(Xeption exception) + private async ValueTask CreateAndLogServiceExceptionAsync( + Xeption exception) { var pdsOrchestrationServiceException = new PdsOrchestrationServiceException( message: "PDS orchestration service error occurred, please contact support.", innerException: exception); + await this.loggingBroker.LogErrorAsync(pdsOrchestrationServiceException); + return pdsOrchestrationServiceException; } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs index 2833e59..4c68d34 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs @@ -3,6 +3,7 @@ // --------------------------------------------------------- using System.Threading; using System.Threading.Tasks; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; using NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds; @@ -13,13 +14,16 @@ internal sealed partial class PdsOrchestrationService : IPdsOrchestrationService { private readonly ICareIdentityService careIdentityService; private readonly IPdsService pdsService; + private readonly ILoggingBroker loggingBroker; public PdsOrchestrationService( ICareIdentityService careIdentityService, - IPdsService pdsService) + IPdsService pdsService, + ILoggingBroker loggingBroker) { this.careIdentityService = careIdentityService; this.pdsService = pdsService; + this.loggingBroker = loggingBroker; } public ValueTask SearchPatientsAsync( diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs index e90c62d..a7c2d5b 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs @@ -21,36 +21,70 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun { return await returningTaskFunction(); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw await CreateAndLogTimeoutDependencyExceptionAsync(); + } + catch (OperationCanceledException) + { + throw; + } catch (InvalidArgumentCareIdentityServiceProcessingException invalidArgumentCareIdentityServiceProcessingException) { - throw await CreateValidationExceptionAsync(invalidArgumentCareIdentityServiceProcessingException); + throw await CreateAndLogValidationExceptionAsync( + invalidArgumentCareIdentityServiceProcessingException); } catch (UnauthorisedCareIdentityServiceProcessingException unauthorisedCareIdentityServiceProcessingException) { - throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceProcessingException); + throw await CreateAndLogValidationExceptionAsync( + unauthorisedCareIdentityServiceProcessingException); } - catch (OperationCanceledException) + catch (CareIdentityServiceProcessingValidationException) + { + throw; + } + catch (CareIdentityServiceProcessingDependencyValidationException) + { + throw; + } + catch (CareIdentityServiceProcessingDependencyException) + { + throw; + } + catch (CareIdentityServiceProcessingServiceException) { throw; } catch (CareIdentityServiceValidationException careIdentityServiceValidationException) { - throw await CreateDependencyValidationExceptionAsync(careIdentityServiceValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync(careIdentityServiceValidationException); } catch (CareIdentityServiceDependencyValidationException careIdentityServiceDependencyValidationException) { - throw await CreateDependencyValidationExceptionAsync(careIdentityServiceDependencyValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync( + careIdentityServiceDependencyValidationException); } catch (CareIdentityServiceDependencyException careIdentityServiceDependencyException) { - throw await CreateDependencyExceptionAsync(careIdentityServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceDependencyException); } catch (CareIdentityServiceServiceException careIdentityServiceServiceException) { - throw await CreateDependencyExceptionAsync(careIdentityServiceServiceException); + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceServiceException); + } + catch (TimeoutException timeoutException) + { + var timeoutCareIdentityServiceProcessingException = + new TimeoutCareIdentityServiceProcessingException( + message: "Failed care identity service processing timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + throw await CreateAndLogTimeoutExceptionAsync(timeoutCareIdentityServiceProcessingException); } catch (Exception exception) { @@ -60,7 +94,7 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedCareIdentityServiceProcessingException); + throw await CreateAndLogServiceExceptionAsync(failedCareIdentityServiceProcessingException); } } @@ -70,36 +104,70 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti { await returningNothingFunction(); } + catch (OperationCanceledException operationCanceledException) + when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + { + throw await CreateAndLogTimeoutDependencyExceptionAsync(); + } + catch (OperationCanceledException) + { + throw; + } catch (InvalidArgumentCareIdentityServiceProcessingException invalidArgumentCareIdentityServiceProcessingException) { - throw await CreateValidationExceptionAsync(invalidArgumentCareIdentityServiceProcessingException); + throw await CreateAndLogValidationExceptionAsync( + invalidArgumentCareIdentityServiceProcessingException); } catch (UnauthorisedCareIdentityServiceProcessingException unauthorisedCareIdentityServiceProcessingException) { - throw await CreateValidationExceptionAsync(unauthorisedCareIdentityServiceProcessingException); + throw await CreateAndLogValidationExceptionAsync( + unauthorisedCareIdentityServiceProcessingException); } - catch (OperationCanceledException) + catch (CareIdentityServiceProcessingValidationException) + { + throw; + } + catch (CareIdentityServiceProcessingDependencyValidationException) + { + throw; + } + catch (CareIdentityServiceProcessingDependencyException) + { + throw; + } + catch (CareIdentityServiceProcessingServiceException) { throw; } catch (CareIdentityServiceValidationException careIdentityServiceValidationException) { - throw await CreateDependencyValidationExceptionAsync(careIdentityServiceValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync(careIdentityServiceValidationException); } catch (CareIdentityServiceDependencyValidationException careIdentityServiceDependencyValidationException) { - throw await CreateDependencyValidationExceptionAsync(careIdentityServiceDependencyValidationException); + throw await CreateAndLogDependencyValidationExceptionAsync( + careIdentityServiceDependencyValidationException); } catch (CareIdentityServiceDependencyException careIdentityServiceDependencyException) { - throw await CreateDependencyExceptionAsync(careIdentityServiceDependencyException); + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceDependencyException); } catch (CareIdentityServiceServiceException careIdentityServiceServiceException) { - throw await CreateDependencyExceptionAsync(careIdentityServiceServiceException); + throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceServiceException); + } + catch (TimeoutException timeoutException) + { + var timeoutCareIdentityServiceProcessingException = + new TimeoutCareIdentityServiceProcessingException( + message: "Failed care identity service processing timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + throw await CreateAndLogTimeoutExceptionAsync(timeoutCareIdentityServiceProcessingException); } catch (Exception exception) { @@ -109,12 +177,40 @@ private async ValueTask TryCatch(ReturningNothingFunction returningNothingFuncti innerException: exception, data: exception.Data); - throw await CreateServiceExceptionAsync(failedCareIdentityServiceProcessingException); + throw await CreateAndLogServiceExceptionAsync(failedCareIdentityServiceProcessingException); } } - private async ValueTask CreateValidationExceptionAsync( + private async ValueTask + CreateAndLogTimeoutDependencyExceptionAsync() + { + var timeoutException = + new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceProcessingException = + new TimeoutCareIdentityServiceProcessingException( + message: "Failed care identity service processing timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + return await CreateAndLogTimeoutExceptionAsync(timeoutCareIdentityServiceProcessingException); + } + + private async ValueTask CreateAndLogTimeoutExceptionAsync( Xeption exception) + { + var careIdentityServiceProcessingDependencyException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: exception); + + await this.loggingBroker.LogErrorAsync(careIdentityServiceProcessingDependencyException); + + return careIdentityServiceProcessingDependencyException; + } + + private async ValueTask + CreateAndLogValidationExceptionAsync(Xeption exception) { var careIdentityServiceProcessingValidationException = new CareIdentityServiceProcessingValidationException( message: "Care identity service processing validation error occurred, " + @@ -122,11 +218,13 @@ private async ValueTask Create innerException: exception); + await this.loggingBroker.LogErrorAsync(careIdentityServiceProcessingValidationException); + return careIdentityServiceProcessingValidationException; } private async ValueTask - CreateDependencyValidationExceptionAsync(Xeption exception) + CreateAndLogDependencyValidationExceptionAsync(Xeption exception) { var careIdentityServiceProcessingDependencyValidationException = new CareIdentityServiceProcessingDependencyValidationException( @@ -135,27 +233,33 @@ private async ValueTask CreateDependencyExceptionAsync( - Xeption exception) + private async ValueTask + CreateAndLogDependencyExceptionAsync(Xeption exception) { var careIdentityServiceProcessingDependencyException = new CareIdentityServiceProcessingDependencyException( message: "Care identity service processing dependency error occurred, please contact support.", innerException: exception.InnerException as Xeption); + await this.loggingBroker.LogErrorAsync(careIdentityServiceProcessingDependencyException); + return careIdentityServiceProcessingDependencyException; } - private async ValueTask CreateServiceExceptionAsync( + private async ValueTask CreateAndLogServiceExceptionAsync( Xeption exception) { var careIdentityServiceProcessingServiceException = new CareIdentityServiceProcessingServiceException( message: "Care identity service processing error occurred, please contact support.", innerException: exception); + await this.loggingBroker.LogErrorAsync(careIdentityServiceProcessingServiceException); + return careIdentityServiceProcessingServiceException; } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs index 49f3c2f..b7337cf 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; +using NHSDigital.ApiPlatform.Sdk.Brokers.Loggings; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; using NHSDigital.ApiPlatform.Sdk.Services.Foundations.CareIdentityServices; @@ -12,9 +13,15 @@ namespace NHSDigital.ApiPlatform.Sdk.Services.Processings.CareIdentityServices internal partial class CareIdentityServiceProcessingService : ICareIdentityServiceProcessingService { private readonly ICareIdentityService careIdentityService; + private readonly ILoggingBroker loggingBroker; - public CareIdentityServiceProcessingService(ICareIdentityService careIdentityService) => + public CareIdentityServiceProcessingService( + ICareIdentityService careIdentityService, + ILoggingBroker loggingBroker) + { this.careIdentityService = careIdentityService; + this.loggingBroker = loggingBroker; + } public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken = default) => TryCatch(async () => From 2d9726660850555d3827b0915f3f55339d1a6bc2 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:27:05 +0100 Subject: [PATCH 08/17] SdkCallerTokenTimeoutDiscriminationTests -> FAIL --- .../CareIdentityServiceClientTests.Cancellations.cs | 13 +++++++++---- ...rviceClientTests.Cancellations.SearchPatients.cs | 6 ++++-- .../CareIdentityServiceTests.Cancellations.cs | 6 ++++-- .../PdsServiceTests.Cancellations.SearchPatients.cs | 7 +++++-- ...tionServiceTests.Cancellations.SearchPatients.cs | 6 ++++-- ...tyServiceProcessingServiceTests.Cancellations.cs | 5 +++-- 6 files changed, 29 insertions(+), 14 deletions(-) diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs index b5d8169..4ca9995 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -75,7 +75,8 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnBuildLoginUrlIfCancel .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when - ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); + ValueTask buildLoginUrlTask = + this.careIdentityServiceClient.BuildLoginUrlAsync(cancellationTokenSource.Token); // then await Assert.ThrowsAsync(async () => await buildLoginUrlTask); @@ -93,7 +94,8 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnLogoutIfCancellationR .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when - ValueTask logoutTask = this.careIdentityServiceClient.LogoutAsync(); + ValueTask logoutTask = + this.careIdentityServiceClient.LogoutAsync(cancellationTokenSource.Token); // then await Assert.ThrowsAsync(async () => await logoutTask); @@ -117,7 +119,10 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnGetUserInfoIfCancella // when ValueTask getUserInfoTask = - this.careIdentityServiceClient.GetUserInfoAsync(randomCode, randomState); + this.careIdentityServiceClient.GetUserInfoAsync( + randomCode, + randomState, + cancellationTokenSource.Token); // then await Assert.ThrowsAsync(async () => await getUserInfoTask); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs index f47d97f..8e22f17 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -81,7 +81,9 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsIfCance // when ValueTask searchPatientsTask = - this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + this.personalDemographicsServiceClient.SearchPatientsAsync( + randomSearchCriteria, + cancellationTokenSource.Token); // then await Assert.ThrowsAsync(async () => await searchPatientsTask); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs index 9f602b1..13f3789 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -124,7 +124,9 @@ public async Task ShouldNotWrapOperationCanceledExceptionRaisedByABrokerOnGetUse // when ValueTask getUserInfoTask = - this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + this.careIdentityService.GetUserInfoAsync( + randomAccessToken, + cancellationTokenSource.Token); // then await Assert.ThrowsAsync(async () => await getUserInfoTask); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs index 1e17d4b..feb19d2 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -54,7 +54,10 @@ public async Task ShouldNotWrapOperationCanceledExceptionRaisedByABrokerOnSearch // when ValueTask searchPatientsTask = - this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + this.pdsService.SearchPatientsAsync( + randomAccessToken, + randomSearchCriteria, + cancellationTokenSource.Token); // then await Assert.ThrowsAsync(async () => await searchPatientsTask); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs index 4f802c1..1883a5f 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -80,7 +80,9 @@ public async Task ShouldNotWrapOperationCanceledExceptionRaisedByADependencyOnSe // when ValueTask searchPatientsTask = - this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + this.pdsOrchestrationService.SearchPatientsAsync( + randomSearchCriteria, + cancellationTokenSource.Token); // then await Assert.ThrowsAsync(async () => await searchPatientsTask); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs index e98cffa..fa23055 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -158,7 +158,8 @@ public async Task ShouldNotWrapOperationCanceledExceptionRaisedByADependencyOnGe // when ValueTask getAccessTokenTask = - this.careIdentityServiceProcessingService.GetAccessTokenAsync(); + this.careIdentityServiceProcessingService.GetAccessTokenAsync( + cancellationTokenSource.Token); // then await Assert.ThrowsAsync(async () => await getAccessTokenTask); From 4e87be1a246c62d94faec49a7c7237376aef9cc6 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:27:06 +0100 Subject: [PATCH 09/17] SdkCallerTokenTimeoutDiscriminationTests -> PASS --- .../CareIdentityServiceClient.cs | 16 ++++++++-------- .../PersonalDemographicsServiceClient.cs | 4 ++-- .../CareIdentityService.Exceptions.cs | 19 ++++++++++++------- .../CareIdentityService.cs | 14 +++++++------- .../Foundations/Pds/PdsService.Exceptions.cs | 11 +++++++---- .../Services/Foundations/Pds/PdsService.cs | 2 +- .../Pds/PdsOrchestrationService.Exceptions.cs | 11 +++++++---- .../Pds/PdsOrchestrationService.cs | 4 ++-- ...tityServiceProcessingService.Exceptions.cs | 19 ++++++++++++------- .../CareIdentityServiceProcessingService.cs | 10 +++++----- 10 files changed, 63 insertions(+), 47 deletions(-) diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs index d340271..8390488 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs @@ -26,8 +26,8 @@ public async ValueTask BuildLoginUrlAsync(CancellationToken cancellation { return await this.careIdentityServiceProcessingService.BuildLoginUrlAsync(cancellationToken); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw CreateCareIdentityServiceClientTimeoutDependencyException(); } @@ -72,8 +72,8 @@ public async ValueTask LogoutAsync(CancellationToken cancellationToken = default { await this.careIdentityServiceProcessingService.LogoutAsync(cancellationToken); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw CreateCareIdentityServiceClientTimeoutDependencyException(); } @@ -118,8 +118,8 @@ public async ValueTask GetAccessTokenAsync(CancellationToken cancellatio { return await this.careIdentityServiceProcessingService.GetAccessTokenAsync(cancellationToken); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw CreateCareIdentityServiceClientTimeoutDependencyException(); } @@ -167,8 +167,8 @@ public async ValueTask GetUserInfoAsync( { return await this.careIdentityServiceProcessingService.GetUserInfoAsync(code, state, cancellationToken); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw CreateCareIdentityServiceClientTimeoutDependencyException(); } diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs index 9256874..04fc97e 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs @@ -32,8 +32,8 @@ public async ValueTask SearchPatientsAsync( searchCriteria, cancellationToken); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw CreatePersonalDemographicsServiceClientTimeoutDependencyException(); } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs index 048a87f..7a12670 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs @@ -1,9 +1,10 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using Xeptions; @@ -15,14 +16,16 @@ internal partial class CareIdentityService private delegate ValueTask ReturningTaskFunction(); private delegate ValueTask ReturningNothingFunction(); - private async ValueTask TryCatch(ReturningTaskFunction returningTaskFunction) + private async ValueTask TryCatch( + ReturningTaskFunction returningTaskFunction, + CancellationToken cancellationToken) { try { return await returningTaskFunction(); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw await CreateAndLogTimeoutDependencyExceptionAsync(); } @@ -89,14 +92,16 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun } } - private async ValueTask TryCatch(ReturningNothingFunction returningNothingFunction) + private async ValueTask TryCatch( + ReturningNothingFunction returningNothingFunction, + CancellationToken cancellationToken) { try { await returningNothingFunction(); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw await CreateAndLogTimeoutDependencyExceptionAsync(); } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs index 39b87db..1476087 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs @@ -68,7 +68,7 @@ public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken } return url; - }); + }, cancellationToken); public ValueTask LogoutAsync(CancellationToken cancellationToken = default) => TryCatch(async () => @@ -78,7 +78,7 @@ public ValueTask LogoutAsync(CancellationToken cancellationToken = default) => await this.tokenBroker.ClearAccessTokenAsync(cancellationToken); await this.tokenBroker.ClearRefreshTokenAsync(cancellationToken); await this.tokenBroker.ClearActiveRoleAsync(cancellationToken); - }); + }, cancellationToken); public ValueTask CallbackAsync( string code, @@ -120,7 +120,7 @@ await this.tokenBroker.StoreActiveRoleAsync( userInfo.NhsIdNrbacRoles[0].PersonRoleId, cancellationToken); } - }); + }, cancellationToken); public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken = default) => TryCatch(async () => @@ -174,7 +174,7 @@ await this.tokenBroker.StoreRefreshTokenAsync( ValidateAccessToken(refreshed.AccessToken); return refreshed.AccessToken; - }); + }, cancellationToken); public ValueTask GetUserInfoAsync(string accessToken, CancellationToken cancellationToken) => TryCatch(async () => @@ -195,7 +195,7 @@ public ValueTask GetUserInfoAsync(string accessToken, CancellationT NhsUserInfo? userInfo = this.jsonBroker.Deserialize(json); return userInfo ?? throw new InvalidOperationException("UserInfo endpoint returned an invalid payload."); - }); + }, cancellationToken); private ValueTask ExchangeCodeForTokenAsync(string code, CancellationToken cancellationToken) => TryCatch(async () => @@ -222,7 +222,7 @@ private ValueTask ExchangeCodeForTokenAsync(string code, Cancellati TokenResult? token = this.jsonBroker.Deserialize(json); return token ?? throw new InvalidOperationException("Token endpoint returned an invalid payload."); - }); + }, cancellationToken); private ValueTask ExchangeRefreshTokenForTokenAsync( string refreshToken, @@ -251,6 +251,6 @@ private ValueTask ExchangeRefreshTokenForTokenAsync( TokenResult? token = this.jsonBroker.Deserialize(json); return token ?? throw new InvalidOperationException("Token endpoint returned an invalid payload."); - }); + }, cancellationToken); } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs index d5ae219..3ce4b4e 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs @@ -1,9 +1,10 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; using Xeptions; @@ -14,14 +15,16 @@ internal partial class PdsService : IPdsService { private delegate ValueTask ReturningStringFunction(); - private async ValueTask TryCatch(ReturningStringFunction returningStringFunction) + private async ValueTask TryCatch( + ReturningStringFunction returningStringFunction, + CancellationToken cancellationToken) { try { return await returningStringFunction(); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw await CreateAndLogTimeoutDependencyExceptionAsync(); } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs index 10d1020..ae8293f 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs @@ -97,6 +97,6 @@ public ValueTask SearchPatientsAsync( response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(cancellationToken); - }); + }, cancellationToken); } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs index 3c94a1e..a4c5596 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs @@ -1,8 +1,9 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; +using System.Threading; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; @@ -15,14 +16,16 @@ internal sealed partial class PdsOrchestrationService { private delegate ValueTask ReturningStringFunction(); - private async ValueTask TryCatch(ReturningStringFunction returningStringFunction) + private async ValueTask TryCatch( + ReturningStringFunction returningStringFunction, + CancellationToken cancellationToken) { try { return await returningStringFunction(); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw await CreateAndLogTimeoutDependencyExceptionAsync(); } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs index 4c68d34..87240ad 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System.Threading; @@ -38,6 +38,6 @@ public ValueTask SearchPatientsAsync( return await this.pdsService .SearchPatientsAsync(accessToken, searchCriteria, cancellationToken); - }); + }, cancellationToken); } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs index a7c2d5b..a30750d 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs @@ -1,8 +1,9 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; +using System.Threading; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; @@ -15,14 +16,16 @@ internal partial class CareIdentityServiceProcessingService : ICareIdentityServi private delegate ValueTask ReturningTaskFunction(); private delegate ValueTask ReturningNothingFunction(); - private async ValueTask TryCatch(ReturningTaskFunction returningTaskFunction) + private async ValueTask TryCatch( + ReturningTaskFunction returningTaskFunction, + CancellationToken cancellationToken) { try { return await returningTaskFunction(); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw await CreateAndLogTimeoutDependencyExceptionAsync(); } @@ -98,14 +101,16 @@ private async ValueTask TryCatch(ReturningTaskFunction returningTaskFun } } - private async ValueTask TryCatch(ReturningNothingFunction returningNothingFunction) + private async ValueTask TryCatch( + ReturningNothingFunction returningNothingFunction, + CancellationToken cancellationToken) { try { await returningNothingFunction(); } - catch (OperationCanceledException operationCanceledException) - when (operationCanceledException.CancellationToken.IsCancellationRequested is false) + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested is false) { throw await CreateAndLogTimeoutDependencyExceptionAsync(); } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs index b7337cf..9989924 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -29,14 +29,14 @@ public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken cancellationToken.ThrowIfCancellationRequested(); return await this.careIdentityService.BuildLoginUrlAsync(cancellationToken); - }); + }, cancellationToken); public ValueTask LogoutAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { cancellationToken.ThrowIfCancellationRequested(); await this.careIdentityService.LogoutAsync(cancellationToken); - }); + }, cancellationToken); public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken = default) => TryCatch(async () => @@ -44,7 +44,7 @@ public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken cancellationToken.ThrowIfCancellationRequested(); return await this.careIdentityService.GetAccessTokenAsync(cancellationToken); - }); + }, cancellationToken); public ValueTask GetUserInfoAsync( string code, @@ -62,6 +62,6 @@ public ValueTask GetUserInfoAsync( .GetUserInfoAsync(accessToken, cancellationToken); return userInfo; - }); + }, cancellationToken); } } From d7d1f7c42118339a192ae95182e018dadc3b1def Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 15:49:06 +0100 Subject: [PATCH 10/17] CODE RUB: Address Copilot Review Feedback On Sdk Formatting And Date Interpolation --- .../CareIdentityServiceTests.Timeouts.cs | 4 ++-- .../Pds/PdsServiceTests.Timeouts.SearchPatients.cs | 4 ++-- ...OrchestrationServiceTests.Cancellations.SearchPatients.cs | 2 +- ...PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs | 2 +- .../PersonalDemographicsServiceClient.cs | 4 ++-- .../TimeoutPersonalDemographicsServiceClientException.cs | 5 ++++- .../Services/Foundations/Pds/PdsService.cs | 2 +- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs index 7d28eeb..7ad6286 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Timeouts.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -112,7 +112,7 @@ await Assert.ThrowsAsync( } [Fact] - public async Task ShouldThrowDependencyExceptionOnGetAccessTokenIfOperationCanceledExceptionOccursAndLogItAsync() + public async Task ShouldThrowDependencyExceptionOnGetAccessTokenIfOperationCanceledOccursAndLogItAsync() { // given var operationCanceledException = new OperationCanceledException(); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs index 6f5b0ea..85a2f44 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Timeouts.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -17,7 +17,7 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds public partial class PdsServiceTests { [Fact] - public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfOperationCanceledExceptionOccursAndLogItAsync() + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfOperationCanceledOccursAndLogItAsync() { // given string randomAccessToken = GetRandomString(); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs index 1883a5f..71cc223 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs @@ -16,7 +16,7 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds public partial class PdsOrchestrationServiceTests { [Fact] - public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfOperationCanceledExceptionOccursAndLogItAsync() + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfOperationCanceledOccursAndLogItAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs index f803096..82eac5f 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs @@ -18,7 +18,7 @@ public partial class PdsOrchestrationServiceTests { [Theory] [MemberData(nameof(DependencyValidationExceptions))] - public async Task ShouldThrowDependencyValidationExceptionOnSearchPatientsIfDependencyValidationErrorOccursAsync( + public async Task ShouldThrowDependencyValidationExceptionOnSearchPatientsIfValidationErrorOccursAsync( Xeption dependencyValidationException) { // given diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs index 04fc97e..f007c80 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs @@ -23,13 +23,13 @@ public PersonalDemographicsServiceClient(IPdsOrchestrationService pdsOrchestrati this.pdsOrchestrationService = pdsOrchestrationService; public async ValueTask SearchPatientsAsync( - SearchCriteria searchCriteria, + SearchCriteria searchCriteria, CancellationToken cancellationToken = default) { try { return await this.pdsOrchestrationService.SearchPatientsAsync( - searchCriteria, + searchCriteria, cancellationToken); } catch (OperationCanceledException) diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs index 3bcc494..5c642cf 100644 --- a/NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs +++ b/NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs @@ -10,7 +10,10 @@ namespace NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions { public class TimeoutPersonalDemographicsServiceClientException : Xeption { - public TimeoutPersonalDemographicsServiceClientException(string message, Exception innerException, IDictionary data) + public TimeoutPersonalDemographicsServiceClientException( + string message, + Exception innerException, + IDictionary data) : base(message, innerException, data) { } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs index ae8293f..7052717 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs @@ -68,7 +68,7 @@ public ValueTask SearchPatientsAsync( if (!string.IsNullOrWhiteSpace(searchCriteria.DateOfBirth)) { - url += $"&birthdate=eq{searchCriteria.DateOfBirth:yyyy-MM-dd}"; + url += $"&birthdate=eq{Uri.EscapeDataString(searchCriteria.DateOfBirth)}"; } if (!string.IsNullOrWhiteSpace(searchCriteria.Postcode)) From b515d3984f06ac6226f64cd5b739e00df046b6c3 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 17:26:44 +0100 Subject: [PATCH 11/17] SdkReviewRemediationTests -> FAIL --- .../SessionApiPlatformTokenBrokerTests.cs | 37 ++- ...dentityServiceClientTests.Cancellations.cs | 132 ++++++----- ...iceClientTests.Exceptions.AllOperations.cs | 214 ++++++++++++++++++ ...reIdentityServiceClientTests.Exceptions.cs | 5 +- ...lientTests.Cancellations.SearchPatients.cs | 95 ++++---- ...ceClientTests.Exceptions.SearchPatients.cs | 4 +- .../CareIdentityServiceTests.Cancellations.cs | 12 +- .../CareIdentityServiceTests.Contracts.cs | 208 +++++++++++++++++ ...entityServiceTests.Logic.GetAccessToken.cs | 70 +++++- ...rviceTests.Cancellations.SearchPatients.cs | 12 +- ...dsServiceTests.Contracts.SearchPatients.cs | 160 +++++++++++++ ...rviceTests.Cancellations.SearchPatients.cs | 113 ++++----- ...nServiceTests.Exceptions.SearchPatients.cs | 2 +- ...iceProcessingServiceTests.Cancellations.cs | 132 +++++------ 14 files changed, 937 insertions(+), 259 deletions(-) create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.AllOperations.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Contracts.cs create mode 100644 NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs index 09b07ae..0922976 100644 --- a/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Unit/Brokers/Storages/SessionApiPlatformTokenBrokerTests.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -113,6 +113,38 @@ public async Task ShouldStoreRefreshTokenAsync() actualToken.Should().Be(randomRefreshToken); } + [Fact] + public async Task ShouldStoreRefreshTokenExpiryToTheSecondAsync() + { + // given + // This expiry is the sole input to the decision to silently refresh or sign the user out, + // so the round trip through the session has to preserve it. + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomExpiresAtUtc = GetRandomDateTimeOffset(); + + DateTimeOffset expectedExpiresAtUtc = + DateTimeOffset.FromUnixTimeSeconds(randomExpiresAtUtc.ToUnixTimeSeconds()); + + // when + await this.apiPlatformTokenBroker.StoreRefreshTokenAsync(randomRefreshToken, randomExpiresAtUtc); + + // then + var (_, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetRefreshTokenAsync(); + actualExpiresAtUtc.Should().Be(expectedExpiresAtUtc); + } + + [Fact] + public async Task ShouldReturnNullsOnGetRefreshTokenIfTokenWasNeverStoredAsync() + { + // given + // when + var (actualToken, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetRefreshTokenAsync(); + + // then + actualToken.Should().BeNull(); + actualExpiresAtUtc.Should().BeNull(); + } + [Fact] public async Task ShouldClearRefreshTokenAsync() { @@ -125,8 +157,9 @@ await this.apiPlatformTokenBroker.StoreRefreshTokenAsync( await this.apiPlatformTokenBroker.ClearRefreshTokenAsync(); // then - var (actualToken, _) = await this.apiPlatformTokenBroker.GetRefreshTokenAsync(); + var (actualToken, actualExpiresAtUtc) = await this.apiPlatformTokenBroker.GetRefreshTokenAsync(); actualToken.Should().BeNull(); + actualExpiresAtUtc.Should().BeNull(); } [Fact] diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs index 4ca9995..0ab7357 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Cancellations.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -9,6 +9,8 @@ using Moq; using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; using Xunit; namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices @@ -16,55 +18,7 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices public partial class CareIdentityServiceClientTests { [Fact] - public async Task ShouldThrowClientDependencyExceptionOnBuildLoginUrlIfOperationCanceledExceptionOccursAsync() - { - // given - var operationCanceledException = new OperationCanceledException(); - - CareIdentityServiceClientDependencyException expectedException = - CreateExpectedTimeoutDependencyException(); - - this.careIdentityServiceProcessingServiceMock.Setup(service => - service.BuildLoginUrlAsync(It.IsAny())) - .ThrowsAsync(operationCanceledException); - - // when - ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); - - CareIdentityServiceClientDependencyException actualException = - await Assert.ThrowsAsync( - async () => await buildLoginUrlTask); - - // then - actualException.Should().BeEquivalentTo(expectedException); - } - - [Fact] - public async Task ShouldThrowClientDependencyExceptionOnGetAccessTokenIfTaskCanceledExceptionOccursAsync() - { - // given - var taskCanceledException = new TaskCanceledException(); - - CareIdentityServiceClientDependencyException expectedException = - CreateExpectedTimeoutDependencyException(); - - this.careIdentityServiceProcessingServiceMock.Setup(service => - service.GetAccessTokenAsync(It.IsAny())) - .ThrowsAsync(taskCanceledException); - - // when - ValueTask getAccessTokenTask = this.careIdentityServiceClient.GetAccessTokenAsync(); - - CareIdentityServiceClientDependencyException actualException = - await Assert.ThrowsAsync( - async () => await getAccessTokenTask); - - // then - actualException.Should().BeEquivalentTo(expectedException); - } - - [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnBuildLoginUrlIfCancellationRequestedAsync() + public async Task ShouldRethrowOperationCanceledExceptionOnBuildLoginUrlIfCancellationRequestedAsync() { // given using var cancellationTokenSource = new CancellationTokenSource(); @@ -83,7 +37,7 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnBuildLoginUrlIfCancel } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnLogoutIfCancellationRequestedAsync() + public async Task ShouldRethrowOperationCanceledExceptionOnLogoutIfCancellationRequestedAsync() { // given using var cancellationTokenSource = new CancellationTokenSource(); @@ -102,7 +56,7 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnLogoutIfCancellationR } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnGetUserInfoIfCancellationRequestedAsync() + public async Task ShouldRethrowOperationCanceledExceptionOnGetUserInfoIfCancellationRequestedAsync() { // given string randomCode = GetRandomString(); @@ -128,6 +82,64 @@ public async Task ShouldNotWrapOperationCanceledExceptionOnGetUserInfoIfCancella await Assert.ThrowsAsync(async () => await getUserInfoTask); } + [Fact] + public async Task ShouldRethrowCancellationRaisedByTheProcessingServiceOnGetAccessTokenAsync() + { + // given + // The token is live when the call starts; the dependency is what raises the cancellation. + using var cancellationTokenSource = new CancellationTokenSource(); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + + // when + ValueTask getAccessTokenTask = + this.careIdentityServiceClient.GetAccessTokenAsync(cancellationTokenSource.Token); + + // then + await Assert.ThrowsAsync(async () => await getAccessTokenTask); + } + + [Fact] + public async Task ShouldSurfaceAProcessingTimeoutAsClientDependencyExceptionOnBuildLoginUrlAsync() + { + // given + var timeoutException = new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + var processingDependencyException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: timeoutCareIdentityServiceException); + + var expectedException = + new CareIdentityServiceClientDependencyException( + message: "Care identity service client dependency error occurred, contact support.", + innerException: timeoutCareIdentityServiceException); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(processingDependencyException); + + // when + ValueTask buildLoginUrlTask = this.careIdentityServiceClient.BuildLoginUrlAsync(); + + CareIdentityServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + actualException.InnerException.InnerException.Should().BeOfType(); + } + [Fact] public async Task ShouldPropagateCancellationTokenToProcessingServiceOnGetUserInfoAsync() { @@ -152,21 +164,5 @@ public async Task ShouldPropagateCancellationTokenToProcessingServiceOnGetUserIn service.GetUserInfoAsync(randomCode, randomState, cancellationToken), Times.Once); } - - private static CareIdentityServiceClientDependencyException CreateExpectedTimeoutDependencyException() - { - var timeoutException = - new TimeoutException("The dependency operation timed out."); - - var timeoutCareIdentityServiceClientException = - new TimeoutCareIdentityServiceClientException( - message: "Failed care identity service client timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - return new CareIdentityServiceClientDependencyException( - message: "Care identity service client dependency error occurred, contact support.", - innerException: timeoutCareIdentityServiceClientException); - } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.AllOperations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.AllOperations.cs new file mode 100644 index 0000000..a831458 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.AllOperations.cs @@ -0,0 +1,214 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; +using Xeptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.CareIdentityServices +{ + public partial class CareIdentityServiceClientTests + { + // The client repeats the same catch ladder in all four of its methods, so each category is + // exercised against every method rather than against whichever one happened to be picked. + public static TheoryData ClientOperations() => + new TheoryData + { + nameof(ICareIdentityServiceClientOperation.BuildLoginUrl), + nameof(ICareIdentityServiceClientOperation.Logout), + nameof(ICareIdentityServiceClientOperation.GetAccessToken), + nameof(ICareIdentityServiceClientOperation.GetUserInfo) + }; + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldThrowClientValidationExceptionOnEveryOperationIfValidationErrorOccursAsync( + string operation) + { + // given + var innerException = new Xeption(message: GetRandomString()); + + var processingValidationException = + new CareIdentityServiceProcessingValidationException(GetRandomString(), innerException); + + SetupEveryOperationToThrow(processingValidationException); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeOfType(); + actualException.InnerException.Should().BeSameAs(innerException); + } + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldThrowClientDependencyValidationExceptionOnEveryOperationAsync(string operation) + { + // given + var innerException = new Xeption(message: GetRandomString()); + + var processingDependencyValidationException = + new CareIdentityServiceProcessingDependencyValidationException( + GetRandomString(), + innerException); + + SetupEveryOperationToThrow(processingDependencyValidationException); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeOfType(); + actualException.InnerException.Should().BeSameAs(innerException); + } + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldThrowClientDependencyExceptionOnEveryOperationIfDependencyErrorOccursAsync( + string operation) + { + // given + var innerException = new Xeption(message: GetRandomString()); + + var processingDependencyException = + new CareIdentityServiceProcessingDependencyException(GetRandomString(), innerException); + + SetupEveryOperationToThrow(processingDependencyException); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeOfType(); + actualException.InnerException.Should().BeSameAs(innerException); + } + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldThrowClientServiceExceptionOnEveryOperationIfServiceErrorOccursAsync( + string operation) + { + // given + var innerException = new Xeption(message: GetRandomString()); + + var processingServiceException = + new CareIdentityServiceProcessingServiceException(GetRandomString(), innerException); + + SetupEveryOperationToThrow(processingServiceException); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeOfType(); + actualException.InnerException.Should().BeSameAs(innerException); + } + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldThrowClientServiceExceptionOnEveryOperationIfUnexpectedErrorOccursAsync( + string operation) + { + // given + var unexpectedException = new InvalidOperationException(GetRandomString()); + SetupEveryOperationToThrow(unexpectedException); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeOfType(); + + actualException.InnerException + .Should().BeOfType(); + + actualException.InnerException.InnerException.Should().BeSameAs(unexpectedException); + } + + [Theory] + [MemberData(nameof(ClientOperations))] + public async Task ShouldRethrowOperationCanceledExceptionOnEveryOperationAsync(string operation) + { + // given + SetupEveryOperationToThrow(new OperationCanceledException()); + + // when + Exception actualException = await InvokeOperationAsync(operation); + + // then + actualException.Should().BeAssignableTo(); + } + + private void SetupEveryOperationToThrow(Exception exception) + { + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(exception); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.LogoutAsync(It.IsAny())) + .ThrowsAsync(exception); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .ThrowsAsync(exception); + + this.careIdentityServiceProcessingServiceMock.Setup(service => + service.GetUserInfoAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(exception); + } + + private async Task InvokeOperationAsync(string operation) + { + Exception actualException = await Record.ExceptionAsync(async () => + { + switch (operation) + { + case nameof(ICareIdentityServiceClientOperation.BuildLoginUrl): + await this.careIdentityServiceClient.BuildLoginUrlAsync(); + break; + + case nameof(ICareIdentityServiceClientOperation.Logout): + await this.careIdentityServiceClient.LogoutAsync(); + break; + + case nameof(ICareIdentityServiceClientOperation.GetAccessToken): + await this.careIdentityServiceClient.GetAccessTokenAsync(); + break; + + default: + await this.careIdentityServiceClient.GetUserInfoAsync( + GetRandomString(), + GetRandomString()); + + break; + } + }); + + actualException.Should().NotBeNull( + "every operation must surface the failure raised by the processing service"); + + return actualException; + } + + // Names only - gives the theory data compile-time safety against a renamed client operation. + private interface ICareIdentityServiceClientOperation + { + void BuildLoginUrl(); + void Logout(); + void GetAccessToken(); + void GetUserInfo(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs index fc142a7..35d9582 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/CareIdentityServices/CareIdentityServiceClientTests.Exceptions.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -50,7 +50,8 @@ public async Task ShouldThrowClientDependencyValidationExceptionOnLogoutIfDepend // given var expectedException = new CareIdentityServiceClientDependencyValidationException( - message: "Care identity service client validation error occurred, fix errors and try again.", + message: "Care identity service client dependency validation error occurred, " + + "fix errors and try again.", innerException: dependencyValidationException.InnerException as Xeption); this.careIdentityServiceProcessingServiceMock.Setup(service => diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs index 8e22f17..2d2524b 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Cancellations.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -9,6 +9,8 @@ using Moq; using NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; using Xunit; namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServices @@ -16,77 +18,89 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Clients.PersonalDemographicsServ public partial class PersonalDemographicsServiceClientTests { [Fact] - public async Task ShouldThrowClientDependencyExceptionOnSearchPatientsIfOperationCanceledExceptionOccursAsync() + public async Task ShouldRethrowOperationCanceledExceptionOnSearchPatientsIfCancellationRequestedAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); - var operationCanceledException = new OperationCanceledException(); - - PersonalDemographicsServiceClientDependencyException expectedException = - CreateExpectedTimeoutDependencyException(); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); this.pdsOrchestrationServiceMock.Setup(service => service.SearchPatientsAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(operationCanceledException); + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when ValueTask searchPatientsTask = - this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); - - PersonalDemographicsServiceClientDependencyException actualException = - await Assert.ThrowsAsync( - async () => await searchPatientsTask); + this.personalDemographicsServiceClient.SearchPatientsAsync( + randomSearchCriteria, + cancellationTokenSource.Token); // then - actualException.Should().BeEquivalentTo(expectedException); + await Assert.ThrowsAsync(async () => await searchPatientsTask); } [Fact] - public async Task ShouldThrowClientDependencyExceptionOnSearchPatientsIfTaskCanceledExceptionOccursAsync() + public async Task ShouldRethrowCancellationRaisedByTheOrchestrationOnSearchPatientsAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); - var taskCanceledException = new TaskCanceledException(); - PersonalDemographicsServiceClientDependencyException expectedException = - CreateExpectedTimeoutDependencyException(); + // The token is live when the call starts; the dependency is what raises the cancellation. + using var cancellationTokenSource = new CancellationTokenSource(); this.pdsOrchestrationServiceMock.Setup(service => service.SearchPatientsAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(taskCanceledException); + .Callback(() => cancellationTokenSource.Cancel()) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when ValueTask searchPatientsTask = - this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); - - PersonalDemographicsServiceClientDependencyException actualException = - await Assert.ThrowsAsync( - async () => await searchPatientsTask); + this.personalDemographicsServiceClient.SearchPatientsAsync( + randomSearchCriteria, + cancellationTokenSource.Token); // then - actualException.Should().BeEquivalentTo(expectedException); + await Assert.ThrowsAsync(async () => await searchPatientsTask); } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionOnSearchPatientsIfCancellationRequestedAsync() + public async Task ShouldSurfaceAnOrchestrationTimeoutAsClientDependencyExceptionOnSearchPatientsAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); + var timeoutException = new TimeoutException("The dependency operation timed out."); + + var timeoutPdsServiceException = + new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + var orchestrationDependencyException = + new PdsOrchestrationDependencyException( + message: "PDS orchestration dependency error occurred, please contact support.", + innerException: timeoutPdsServiceException); + + var expectedException = + new PersonalDemographicsServiceClientDependencyException( + message: "Personal demographics service client dependency error occurred, contact support.", + innerException: timeoutPdsServiceException); this.pdsOrchestrationServiceMock.Setup(service => service.SearchPatientsAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + .ThrowsAsync(orchestrationDependencyException); // when ValueTask searchPatientsTask = - this.personalDemographicsServiceClient.SearchPatientsAsync( - randomSearchCriteria, - cancellationTokenSource.Token); + this.personalDemographicsServiceClient.SearchPatientsAsync(randomSearchCriteria); + + PersonalDemographicsServiceClientDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); // then - await Assert.ThrowsAsync(async () => await searchPatientsTask); + actualException.Should().BeEquivalentTo(expectedException); + actualException.InnerException.InnerException.Should().BeOfType(); } [Fact] @@ -111,22 +125,5 @@ await this.personalDemographicsServiceClient.SearchPatientsAsync( service.SearchPatientsAsync(randomSearchCriteria, cancellationToken), Times.Once); } - - private static PersonalDemographicsServiceClientDependencyException - CreateExpectedTimeoutDependencyException() - { - var timeoutException = - new TimeoutException("The dependency operation timed out."); - - var timeoutPersonalDemographicsServiceClientException = - new TimeoutPersonalDemographicsServiceClientException( - message: "Failed personal demographics service client timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - return new PersonalDemographicsServiceClientDependencyException( - message: "Personal demographics service client dependency error occurred, contact support.", - innerException: timeoutPersonalDemographicsServiceClientException); - } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs index de15450..1e03aa3 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.Exceptions.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -57,7 +57,7 @@ public async Task ShouldThrowClientDependencyValidationExceptionOnSearchPatients var expectedException = new PersonalDemographicsServiceClientDependencyValidationException( - message: "Personal demographics service client validation error occurred, " + + message: "Personal demographics service client dependency validation error occurred, " + "fix errors and try again.", innerException: dependencyValidationException.InnerException as Xeption); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs index 13f3789..b18cf85 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Cancellations.cs @@ -112,14 +112,17 @@ public async Task ShouldNotWrapOperationCanceledExceptionRaisedByABrokerOnGetUse { // given string randomAccessToken = GetRandomString(); + + // The token is live at entry so ThrowIfCancellationRequested lets us through; the broker is what + // cancels, which is the path a caller aborting an in-flight request actually takes. using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); this.httpBrokerMock.Setup(broker => broker.GetAsync( It.IsAny(), It.IsAny>(), It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) .Throws(new OperationCanceledException(cancellationTokenSource.Token)); // when @@ -131,6 +134,13 @@ public async Task ShouldNotWrapOperationCanceledExceptionRaisedByABrokerOnGetUse // then await Assert.ThrowsAsync(async () => await getUserInfoTask); + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Once); + this.loggingBrokerMock.VerifyNoOtherCalls(); } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Contracts.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Contracts.cs new file mode 100644 index 0000000..2edf305 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Contracts.cs @@ -0,0 +1,208 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.CareIdentityServices +{ + public partial class CareIdentityServiceTests + { + [Fact] + public async Task ShouldPostTheAuthorizationCodeGrantToTheTokenEndpointOnCallbackAsync() + { + // given + string randomCode = GetRandomString(); + string randomState = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + NhsUserInfo randomUserInfo = CreateRandomNhsUserInfo(); + IEnumerable> actualFormValues = null; + + SetupSuccessfulCallback(randomState, randomDateTimeOffset, randomTokenResult, randomUserInfo); + + this.httpBrokerMock.Setup(broker => + broker.PostFormAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Callback>, CancellationToken>( + (url, formValues, cancellationToken) => actualFormValues = formValues) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(It.IsAny())) + .Returns(randomTokenResult); + + // when + await this.careIdentityService.CallbackAsync(randomCode, randomState); + + // then + Dictionary actualForm = + actualFormValues.ToDictionary(pair => pair.Key, pair => pair.Value); + + actualForm["grant_type"].Should().Be("authorization_code"); + actualForm["code"].Should().Be(randomCode); + actualForm["redirect_uri"].Should().Be(this.apiPlatformConfigurations.CareIdentity.RedirectUri); + actualForm["client_id"].Should().Be(this.apiPlatformConfigurations.CareIdentity.ClientId); + actualForm["client_secret"].Should().Be(this.apiPlatformConfigurations.CareIdentity.ClientSecret); + } + + [Fact] + public async Task ShouldPostTheRefreshTokenGrantToTheTokenEndpointOnGetAccessTokenAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + IEnumerable> actualFormValues = null; + string randomTokenJson = GetRandomString(); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((null, null)); + + this.tokenBrokerMock.Setup(broker => + broker.GetRefreshTokenAsync(It.IsAny())) + .ReturnsAsync((randomRefreshToken, randomDateTimeOffset.AddMinutes(30))); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + this.httpBrokerMock.Setup(broker => + broker.PostFormAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Callback>, CancellationToken>( + (url, formValues, cancellationToken) => actualFormValues = formValues) + .ReturnsAsync(CreateHttpResponse(randomTokenJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomTokenJson)) + .Returns(randomTokenResult); + + // when + await this.careIdentityService.GetAccessTokenAsync(); + + // then + Dictionary actualForm = + actualFormValues.ToDictionary(pair => pair.Key, pair => pair.Value); + + actualForm["grant_type"].Should().Be("refresh_token"); + actualForm["refresh_token"].Should().Be(randomRefreshToken); + actualForm["client_id"].Should().Be(this.apiPlatformConfigurations.CareIdentity.ClientId); + actualForm["client_secret"].Should().Be(this.apiPlatformConfigurations.CareIdentity.ClientSecret); + } + + [Fact] + public async Task ShouldSendTheBearerTokenOnGetUserInfoAsync() + { + // given + string randomAccessToken = GetRandomString(); + string randomUserInfoJson = GetRandomString(); + var actualRequest = new HttpRequestMessage(HttpMethod.Get, "https://localhost/userinfo"); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (url, configureRequest, cancellationToken) => configureRequest(actualRequest)) + .ReturnsAsync(CreateHttpResponse(randomUserInfoJson)); + + this.jsonBrokerMock.Setup(broker => + broker.Deserialize(randomUserInfoJson)) + .Returns(CreateRandomNhsUserInfo()); + + // when + await this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + // then + actualRequest.Headers.Authorization.Scheme.Should().Be("Bearer"); + actualRequest.Headers.Authorization.Parameter.Should().Be(randomAccessToken); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + public async Task ShouldThrowDependencyValidationExceptionOnGetUserInfoIfDependencyRejectsTheRequestAsync( + HttpStatusCode statusCode) + { + // given + string randomAccessToken = GetRandomString(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(statusCode) + { + Content = new StringContent(GetRandomString()) + }); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + CareIdentityServiceDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.InnerException + .Should().BeOfType(); + + actualException.InnerException.InnerException.Should().BeOfType(); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfDependencyFailsAsync( + HttpStatusCode statusCode) + { + // given + string randomAccessToken = GetRandomString(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(statusCode) + { + Content = new StringContent(GetRandomString()) + }); + + // when + ValueTask getUserInfoTask = + this.careIdentityService.GetUserInfoAsync(randomAccessToken, default); + + CareIdentityServiceDependencyException actualException = + await Assert.ThrowsAsync( + async () => await getUserInfoTask); + + // then + actualException.InnerException + .Should().BeOfType(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs index a7574d5..936b716 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -95,15 +95,75 @@ public async Task ShouldRefreshAccessTokenOnGetAccessTokenIfStoredTokenHasExpire { // given string randomRefreshToken = GetRandomString(); + string expiredAccessToken = GetRandomString(); DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); TokenResult randomTokenResult = CreateRandomTokenResult(); - SetupExpiredAccessTokenWithValidRefreshToken(randomRefreshToken, randomDateTimeOffset, randomTokenResult); + + SetupExpiredAccessTokenWithValidRefreshToken( + randomRefreshToken, + randomDateTimeOffset, + randomTokenResult, + storedAccessToken: expiredAccessToken, + storedAccessExpiresAtUtc: randomDateTimeOffset.AddSeconds(-1)); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(randomTokenResult.AccessToken); + actualAccessToken.Should().NotBe(expiredAccessToken); + } + + [Fact] + public async Task ShouldRefreshAccessTokenOnGetAccessTokenIfStoredTokenExpiresInsideTheRefreshSkewAsync() + { + // given + string randomRefreshToken = GetRandomString(); + string nearlyExpiredAccessToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + + // The service refreshes anything expiring within 60 seconds rather than handing back a token + // that will die mid-request. + SetupExpiredAccessTokenWithValidRefreshToken( + randomRefreshToken, + randomDateTimeOffset, + randomTokenResult, + storedAccessToken: nearlyExpiredAccessToken, + storedAccessExpiresAtUtc: randomDateTimeOffset.AddSeconds(59)); // when string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); // then actualAccessToken.Should().Be(randomTokenResult.AccessToken); + actualAccessToken.Should().NotBe(nearlyExpiredAccessToken); + } + + [Fact] + public async Task ShouldReturnStoredAccessTokenOnGetAccessTokenIfItOutlivesTheRefreshSkewAsync() + { + // given + string storedAccessToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + + this.tokenBrokerMock.Setup(broker => + broker.GetAccessTokenAsync(It.IsAny())) + .ReturnsAsync((storedAccessToken, randomDateTimeOffset.AddSeconds(61))); + + this.dateTimeBrokerMock.Setup(broker => + broker.GetCurrentDateTimeOffset()) + .Returns(randomDateTimeOffset); + + // when + string actualAccessToken = await this.careIdentityService.GetAccessTokenAsync(); + + // then + actualAccessToken.Should().Be(storedAccessToken); + + this.tokenBrokerMock.Verify(broker => + broker.GetRefreshTokenAsync(It.IsAny()), + Times.Never); } [Fact] @@ -134,13 +194,15 @@ public async Task ShouldStoreRefreshedAccessTokenOnGetAccessTokenAsync() private void SetupExpiredAccessTokenWithValidRefreshToken( string refreshToken, DateTimeOffset currentDateTimeOffset, - TokenResult refreshedTokenResult) + TokenResult refreshedTokenResult, + string storedAccessToken = null, + DateTimeOffset? storedAccessExpiresAtUtc = null) { string randomTokenJson = GetRandomString(); this.tokenBrokerMock.Setup(broker => broker.GetAccessTokenAsync(It.IsAny())) - .ReturnsAsync((null, null)); + .ReturnsAsync((storedAccessToken, storedAccessExpiresAtUtc)); this.tokenBrokerMock.Setup(broker => broker.GetRefreshTokenAsync(It.IsAny())) diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs index feb19d2..f98a324 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Cancellations.SearchPatients.cs @@ -42,14 +42,17 @@ public async Task ShouldNotWrapOperationCanceledExceptionRaisedByABrokerOnSearch // given string randomAccessToken = GetRandomString(); SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + // The token is live at entry so ThrowIfCancellationRequested lets us through; the broker is what + // cancels, which is the path a caller aborting an in-flight request actually takes. using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); this.httpBrokerMock.Setup(broker => broker.GetAsync( It.IsAny(), It.IsAny>(), It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) .Throws(new OperationCanceledException(cancellationTokenSource.Token)); // when @@ -62,6 +65,13 @@ public async Task ShouldNotWrapOperationCanceledExceptionRaisedByABrokerOnSearch // then await Assert.ThrowsAsync(async () => await searchPatientsTask); + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Once); + this.loggingBrokerMock.VerifyNoOtherCalls(); } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs new file mode 100644 index 0000000..f064162 --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs @@ -0,0 +1,160 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; +using Xunit; + +namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds +{ + public partial class PdsServiceTests + { + [Fact] + public async Task ShouldEscapeTheNhsNumberOnSearchPatientsSoItCannotEscapeThePatientPathAsync() + { + // given + string randomAccessToken = GetRandomString(); + string actualUrl = null; + + // A caller-supplied NHS number must never be able to steer the request somewhere else on the + // authenticated host, nor bolt extra FHIR query parameters onto it. + var traversalSearchCriteria = new SearchCriteria + { + NhsNumber = "../../Practitioner/1?scope=all" + }; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (url, configureRequest, cancellationToken) => actualUrl = url) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, traversalSearchCriteria); + + // then + string expectedBaseUrl = + $"{this.apiPlatformConfigurations.PersonalDemographicsService.BaseUrl}/Patient/"; + + actualUrl.Should().StartWith(expectedBaseUrl); + actualUrl.Should().NotContain("/Practitioner"); + actualUrl.Should().NotContain("?"); + actualUrl.Substring(expectedBaseUrl.Length).Should().NotContain("/"); + } + + [Fact] + public async Task ShouldEscapeDemographicSearchValuesOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + string actualUrl = null; + + var searchCriteria = new SearchCriteria + { + Surname = "O'Brien & Sons", + FirstName = "Anne Marie", + Gender = "female", + Postcode = "E1 6AN" + }; + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (url, configureRequest, cancellationToken) => actualUrl = url) + .ReturnsAsync(CreateHttpResponse(GetRandomString())); + + // when + await this.pdsService.SearchPatientsAsync(randomAccessToken, searchCriteria); + + // then + actualUrl.Should().Contain($"family={Uri.EscapeDataString("O'Brien & Sons")}"); + actualUrl.Should().Contain($"given={Uri.EscapeDataString("Anne Marie")}"); + actualUrl.Should().Contain($"address-postalcode={Uri.EscapeDataString("E1 6AN")}"); + + // The raw ampersand would otherwise have introduced a query parameter of its own. + actualUrl.Should().NotContain("& Sons"); + actualUrl.Should().NotContain("Anne Marie"); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + public async Task ShouldThrowDependencyValidationExceptionOnSearchPatientsIfPdsRejectsTheRequestAsync( + HttpStatusCode statusCode) + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(statusCode) + { + Content = new StringContent(GetRandomString()) + }); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyValidationException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.InnerException.Should().BeOfType(); + actualException.InnerException.InnerException.Should().BeOfType(); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfPdsFailsAsync(HttpStatusCode statusCode) + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(statusCode) + { + Content = new StringContent(GetRandomString()) + }); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria); + + PdsServiceDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); + + // then + actualException.InnerException.Should().BeOfType(); + } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs index 71cc223..0f88e1a 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Cancellations.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -8,6 +8,7 @@ using FluentAssertions; using Moq; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions; using Xunit; @@ -16,78 +17,104 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Orchestrations.Pds public partial class PdsOrchestrationServiceTests { [Fact] - public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfOperationCanceledOccursAndLogItAsync() + public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfCancellationRequestedAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); - var operationCanceledException = new OperationCanceledException(); - - PdsOrchestrationDependencyException expectedPdsOrchestrationDependencyException = - CreateExpectedTimeoutDependencyException(); - - this.careIdentityServiceMock.Setup(service => - service.GetAccessTokenAsync(It.IsAny())) - .ThrowsAsync(operationCanceledException); + var cancellationToken = new CancellationToken(canceled: true); // when - ValueTask searchPatientsTask = - this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); - - PdsOrchestrationDependencyException actualException = - await Assert.ThrowsAsync( - async () => await searchPatientsTask); + ValueTask searchPatientsTask = this.pdsOrchestrationService.SearchPatientsAsync( + randomSearchCriteria, + cancellationToken); // then - actualException.Should().BeEquivalentTo(expectedPdsOrchestrationDependencyException); + await Assert.ThrowsAsync(async () => await searchPatientsTask); - this.loggingBrokerMock.Verify(broker => - broker.LogErrorAsync(It.Is( - SameExceptionAs(expectedPdsOrchestrationDependencyException))), - Times.Once); + this.careIdentityServiceMock.VerifyNoOtherCalls(); + this.pdsServiceMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldThrowOperationCanceledExceptionOnSearchPatientsIfCancellationRequestedAsync() + public async Task ShouldRethrowOperationCanceledExceptionRaisedByADependencyOnSearchPatientsAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); - var cancellationToken = new CancellationToken(canceled: true); + + // The token is live when the call starts, so ThrowIfCancellationRequested lets us through and the + // dependency itself is the one that raises the cancellation. + using var cancellationTokenSource = new CancellationTokenSource(); + + this.careIdentityServiceMock.Setup(service => + service.GetAccessTokenAsync(It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when ValueTask searchPatientsTask = this.pdsOrchestrationService.SearchPatientsAsync( randomSearchCriteria, - cancellationToken); + cancellationTokenSource.Token); // then await Assert.ThrowsAsync(async () => await searchPatientsTask); - this.careIdentityServiceMock.VerifyNoOtherCalls(); - this.pdsServiceMock.VerifyNoOtherCalls(); + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(It.IsAny()), + Times.Once); + this.loggingBrokerMock.VerifyNoOtherCalls(); } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionRaisedByADependencyOnSearchPatientsAsync() + public async Task ShouldSurfaceAFoundationTimeoutAsDependencyExceptionOnSearchPatientsAndLogItAsync() { // given SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); + var timeoutException = new TimeoutException("The dependency operation timed out."); + + var timeoutPdsServiceException = + new TimeoutPdsServiceException( + message: "Failed PDS service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + var pdsServiceDependencyException = + new PdsServiceDependencyException( + message: "PDS service dependency error occurred, please contact support.", + innerException: timeoutPdsServiceException); + + var expectedException = + new PdsOrchestrationDependencyException( + message: "PDS orchestration dependency error occurred, please contact support.", + innerException: timeoutPdsServiceException); this.careIdentityServiceMock.Setup(service => service.GetAccessTokenAsync(It.IsAny())) - .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + .ReturnsAsync(GetRandomString()); + + this.pdsServiceMock.Setup(service => + service.SearchPatientsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(pdsServiceDependencyException); // when ValueTask searchPatientsTask = - this.pdsOrchestrationService.SearchPatientsAsync( - randomSearchCriteria, - cancellationTokenSource.Token); + this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); + + PdsOrchestrationDependencyException actualException = + await Assert.ThrowsAsync( + async () => await searchPatientsTask); // then - await Assert.ThrowsAsync(async () => await searchPatientsTask); + actualException.Should().BeEquivalentTo(expectedException); + actualException.InnerException.InnerException.Should().BeOfType(); - this.loggingBrokerMock.VerifyNoOtherCalls(); + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); } [Fact] @@ -122,21 +149,5 @@ public async Task ShouldPropagateCancellationTokenToDependenciesOnSearchPatients service.SearchPatientsAsync(randomAccessToken, randomSearchCriteria, cancellationToken), Times.Once); } - - private static PdsOrchestrationDependencyException CreateExpectedTimeoutDependencyException() - { - var timeoutException = - new TimeoutException("The dependency operation timed out."); - - var timeoutPdsOrchestrationException = - new TimeoutPdsOrchestrationException( - message: "Failed PDS orchestration timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - return new PdsOrchestrationDependencyException( - message: "PDS orchestration dependency error occurred, fix the errors and try again.", - innerException: timeoutPdsOrchestrationException); - } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs index 82eac5f..b26bee1 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs @@ -59,7 +59,7 @@ public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfDependencyErro var expectedPdsOrchestrationDependencyException = new PdsOrchestrationDependencyException( - message: "PDS orchestration dependency error occurred, fix the errors and try again.", + message: "PDS orchestration dependency error occurred, please contact support.", innerException: dependencyException.InnerException as Xeption); this.careIdentityServiceMock.Setup(service => diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs index fa23055..7feb99f 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Cancellations.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -8,6 +8,7 @@ using FluentAssertions; using Moq; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices; +using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; using Xunit; @@ -15,63 +16,6 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Processings.CareIdentit { public partial class CareIdentityServiceProcessingServiceTests { - [Fact] - public async Task ShouldThrowDependencyExceptionOnBuildLoginUrlIfOperationCanceledExceptionOccursAndLogItAsync() - { - // given - var operationCanceledException = new OperationCanceledException(); - - CareIdentityServiceProcessingDependencyException expectedException = - CreateExpectedTimeoutDependencyException(); - - this.careIdentityServiceMock.Setup(service => - service.BuildLoginUrlAsync(It.IsAny())) - .ThrowsAsync(operationCanceledException); - - // when - ValueTask buildLoginUrlTask = - this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); - - CareIdentityServiceProcessingDependencyException actualException = - await Assert.ThrowsAsync( - async () => await buildLoginUrlTask); - - // then - actualException.Should().BeEquivalentTo(expectedException); - - this.loggingBrokerMock.Verify(broker => - broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), - Times.Once); - } - - [Fact] - public async Task ShouldThrowDependencyExceptionOnLogoutIfTaskCanceledExceptionOccursAndLogItAsync() - { - // given - var taskCanceledException = new TaskCanceledException(); - - CareIdentityServiceProcessingDependencyException expectedException = - CreateExpectedTimeoutDependencyException(); - - this.careIdentityServiceMock.Setup(service => - service.LogoutAsync(It.IsAny())) - .ThrowsAsync(taskCanceledException); - - // when - ValueTask logoutTask = this.careIdentityServiceProcessingService.LogoutAsync(); - - CareIdentityServiceProcessingDependencyException actualException = - await Assert.ThrowsAsync( - async () => await logoutTask); - - // then - actualException.Should().BeEquivalentTo(expectedException); - - this.loggingBrokerMock.Verify(broker => - broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), - Times.Once); - } - [Fact] public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfCancellationRequestedAsync() { @@ -146,27 +90,75 @@ public async Task ShouldThrowOperationCanceledExceptionOnGetUserInfoIfCancellati } [Fact] - public async Task ShouldNotWrapOperationCanceledExceptionRaisedByADependencyOnGetAccessTokenAsync() + public async Task ShouldRethrowOperationCanceledExceptionRaisedByTheFoundationOnGetAccessTokenAsync() { // given + // The token is live when the call starts, so ThrowIfCancellationRequested lets us through and the + // dependency itself is the one that raises the cancellation. using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); this.careIdentityServiceMock.Setup(service => service.GetAccessTokenAsync(It.IsAny())) + .Callback(() => cancellationTokenSource.Cancel()) .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); // when ValueTask getAccessTokenTask = - this.careIdentityServiceProcessingService.GetAccessTokenAsync( - cancellationTokenSource.Token); + this.careIdentityServiceProcessingService.GetAccessTokenAsync(cancellationTokenSource.Token); // then await Assert.ThrowsAsync(async () => await getAccessTokenTask); + this.careIdentityServiceMock.Verify(service => + service.GetAccessTokenAsync(It.IsAny()), + Times.Once); + this.loggingBrokerMock.VerifyNoOtherCalls(); } + [Fact] + public async Task ShouldSurfaceAFoundationTimeoutAsDependencyExceptionOnBuildLoginUrlAndLogItAsync() + { + // given + var timeoutException = new TimeoutException("The dependency operation timed out."); + + var timeoutCareIdentityServiceException = + new TimeoutCareIdentityServiceException( + message: "Failed care identity service timeout error occurred, contact support.", + innerException: timeoutException, + data: timeoutException.Data); + + var careIdentityServiceDependencyException = + new CareIdentityServiceDependencyException( + message: "Care identity service dependency error occurred, please contact support.", + innerException: timeoutCareIdentityServiceException); + + var expectedException = + new CareIdentityServiceProcessingDependencyException( + message: "Care identity service processing dependency error occurred, please contact support.", + innerException: timeoutCareIdentityServiceException); + + this.careIdentityServiceMock.Setup(service => + service.BuildLoginUrlAsync(It.IsAny())) + .ThrowsAsync(careIdentityServiceDependencyException); + + // when + ValueTask buildLoginUrlTask = + this.careIdentityServiceProcessingService.BuildLoginUrlAsync(); + + CareIdentityServiceProcessingDependencyException actualException = + await Assert.ThrowsAsync( + async () => await buildLoginUrlTask); + + // then + actualException.Should().BeEquivalentTo(expectedException); + actualException.InnerException.InnerException.Should().BeOfType(); + + this.loggingBrokerMock.Verify(broker => + broker.LogErrorAsync(It.Is(SameExceptionAs(expectedException))), + Times.Once); + } + [Fact] public async Task ShouldPropagateCancellationTokenToFoundationServiceOnGetUserInfoAsync() { @@ -200,21 +192,5 @@ await this.careIdentityServiceProcessingService.GetUserInfoAsync( service.GetUserInfoAsync(randomAccessToken, cancellationToken), Times.Once); } - - private static CareIdentityServiceProcessingDependencyException CreateExpectedTimeoutDependencyException() - { - var timeoutException = - new TimeoutException("The dependency operation timed out."); - - var timeoutCareIdentityServiceProcessingException = - new TimeoutCareIdentityServiceProcessingException( - message: "Failed care identity service processing timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - return new CareIdentityServiceProcessingDependencyException( - message: "Care identity service processing dependency error occurred, please contact support.", - innerException: timeoutCareIdentityServiceProcessingException); - } } } From 77d1f0a6d53dba1cdb949a92799f42d635998898 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 17:26:45 +0100 Subject: [PATCH 12/17] SdkReviewRemediationTests -> PASS --- ...SDigital.ApiPlatform.Sdk.AspNetCore.csproj | 11 +-- .../CareIdentityServiceClient.cs | 38 +---------- .../PersonalDemographicsServiceClient.cs | 24 +------ ...meoutCareIdentityServiceClientException.cs | 17 ----- ...sonalDemographicsServiceClientException.cs | 20 ------ ...dCareIdentityServiceDependencyException.cs | 16 +++++ .../InvalidPdsServiceDependencyException.cs | 16 +++++ .../TimeoutPdsOrchestrationException.cs | 17 ----- ...tCareIdentityServiceProcessingException.cs | 17 ----- .../NHSDigital.ApiPlatform.Sdk.csproj | 16 +++-- .../CareIdentityService.Exceptions.cs | 47 +++++++++++++ .../CareIdentityService.cs | 1 + .../Foundations/Pds/PdsService.Exceptions.cs | 28 ++++++++ .../Services/Foundations/Pds/PdsService.cs | 2 +- .../Pds/PdsOrchestrationService.Exceptions.cs | 49 +------------- .../Pds/PdsOrchestrationService.cs | 2 +- ...tityServiceProcessingService.Exceptions.cs | 67 +------------------ .../CareIdentityServiceProcessingService.cs | 8 +-- 18 files changed, 139 insertions(+), 257 deletions(-) delete mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Clients/CareIdentityService/Exceptions/TimeoutCareIdentityServiceClientException.cs delete mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidCareIdentityServiceDependencyException.cs create mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/InvalidPdsServiceDependencyException.cs delete mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/TimeoutPdsOrchestrationException.cs delete mode 100644 NHSDigital.ApiPlatform.Sdk/Models/Processings/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceProcessingException.cs diff --git a/NHSDigital.ApiPlatform.Sdk.AspNetCore/NHSDigital.ApiPlatform.Sdk.AspNetCore.csproj b/NHSDigital.ApiPlatform.Sdk.AspNetCore/NHSDigital.ApiPlatform.Sdk.AspNetCore.csproj index 5e0695a..82ff637 100644 --- a/NHSDigital.ApiPlatform.Sdk.AspNetCore/NHSDigital.ApiPlatform.Sdk.AspNetCore.csproj +++ b/NHSDigital.ApiPlatform.Sdk.AspNetCore/NHSDigital.ApiPlatform.Sdk.AspNetCore.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -20,12 +20,13 @@ git NHSISL; NHS Digital; API; Platform; Client; .NET; The Standard; - Release 0.2.0.3: updated NHSDigital.ApiPlatform.Sdk.AspNetCore package metadata. + Release 0.3.0.0: tracks the NHSDigital.ApiPlatform.Sdk 0.3.0.0 exception and + cancellation contract changes. True - 0.2.0.3 - 0.2.0.3 - 0.2.0.3 + 0.3.0.0 + 0.3.0.0 + 0.3.0.0 README.md LICENSE.txt true diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs index 8390488..6df3586 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/CareIdentityServices/CareIdentityServiceClient.cs @@ -26,11 +26,6 @@ public async ValueTask BuildLoginUrlAsync(CancellationToken cancellation { return await this.careIdentityServiceProcessingService.BuildLoginUrlAsync(cancellationToken); } - catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested is false) - { - throw CreateCareIdentityServiceClientTimeoutDependencyException(); - } catch (OperationCanceledException) { throw; @@ -72,11 +67,6 @@ public async ValueTask LogoutAsync(CancellationToken cancellationToken = default { await this.careIdentityServiceProcessingService.LogoutAsync(cancellationToken); } - catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested is false) - { - throw CreateCareIdentityServiceClientTimeoutDependencyException(); - } catch (OperationCanceledException) { throw; @@ -118,11 +108,6 @@ public async ValueTask GetAccessTokenAsync(CancellationToken cancellatio { return await this.careIdentityServiceProcessingService.GetAccessTokenAsync(cancellationToken); } - catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested is false) - { - throw CreateCareIdentityServiceClientTimeoutDependencyException(); - } catch (OperationCanceledException) { throw; @@ -167,11 +152,6 @@ public async ValueTask GetUserInfoAsync( { return await this.careIdentityServiceProcessingService.GetUserInfoAsync(code, state, cancellationToken); } - catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested is false) - { - throw CreateCareIdentityServiceClientTimeoutDependencyException(); - } catch (OperationCanceledException) { throw; @@ -207,21 +187,6 @@ public async ValueTask GetUserInfoAsync( } } - private static CareIdentityServiceClientDependencyException - CreateCareIdentityServiceClientTimeoutDependencyException() - { - var timeoutException = - new TimeoutException("The dependency operation timed out."); - - var timeoutCareIdentityServiceClientException = - new TimeoutCareIdentityServiceClientException( - message: "Failed care identity service client timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - return CreateCareIdentityServiceClientDependencyException(timeoutCareIdentityServiceClientException); - } - private static CareIdentityServiceClientValidationException CreateCareIdentityServiceClientValidationException(Xeption innerException) { @@ -234,7 +199,8 @@ private static CareIdentityServiceClientDependencyValidationException CreateCareIdentityServiceClientDependencyValidationException(Xeption innerException) { return new CareIdentityServiceClientDependencyValidationException( - message: "Care identity service client validation error occurred, fix errors and try again.", + message: "Care identity service client dependency validation error occurred, " + + "fix errors and try again.", innerException); } diff --git a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs index f007c80..4aea33a 100644 --- a/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs +++ b/NHSDigital.ApiPlatform.Sdk/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClient.cs @@ -32,11 +32,6 @@ public async ValueTask SearchPatientsAsync( searchCriteria, cancellationToken); } - catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested is false) - { - throw CreatePersonalDemographicsServiceClientTimeoutDependencyException(); - } catch (OperationCanceledException) { throw; @@ -72,22 +67,6 @@ public async ValueTask SearchPatientsAsync( } } - private static PersonalDemographicsServiceClientDependencyException - CreatePersonalDemographicsServiceClientTimeoutDependencyException() - { - var timeoutException = - new TimeoutException("The dependency operation timed out."); - - var timeoutPersonalDemographicsServiceClientException = - new TimeoutPersonalDemographicsServiceClientException( - message: "Failed personal demographics service client timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - return CreatePersonalDemographicsServiceClientDependencyException( - timeoutPersonalDemographicsServiceClientException); - } - private static PersonalDemographicsServiceClientValidationException CreatePersonalDemographicsServiceClientValidationException(Xeption innerException) { @@ -100,7 +79,8 @@ private static PersonalDemographicsServiceClientDependencyValidationException CreatePersonalDemographicsServiceClientDependencyValidationException(Xeption innerException) { return new PersonalDemographicsServiceClientDependencyValidationException( - message: "Personal demographics service client validation error occurred, fix errors and try again.", + message: "Personal demographics service client dependency validation error occurred, " + + "fix errors and try again.", innerException); } diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Clients/CareIdentityService/Exceptions/TimeoutCareIdentityServiceClientException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Clients/CareIdentityService/Exceptions/TimeoutCareIdentityServiceClientException.cs deleted file mode 100644 index 1b55aaa..0000000 --- a/NHSDigital.ApiPlatform.Sdk/Models/Clients/CareIdentityService/Exceptions/TimeoutCareIdentityServiceClientException.cs +++ /dev/null @@ -1,17 +0,0 @@ -// --------------------------------------------------------- -// Copyright (c) North East London ICB. All rights reserved. -// --------------------------------------------------------- - -using System; -using System.Collections; -using Xeptions; - -namespace NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions -{ - public class TimeoutCareIdentityServiceClientException : Xeption - { - public TimeoutCareIdentityServiceClientException(string message, Exception innerException, IDictionary data) - : base(message, innerException, data) - { } - } -} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs deleted file mode 100644 index 5c642cf..0000000 --- a/NHSDigital.ApiPlatform.Sdk/Models/Clients/Pds/Exceptions/TimeoutPersonalDemographicsServiceClientException.cs +++ /dev/null @@ -1,20 +0,0 @@ -// --------------------------------------------------------- -// Copyright (c) North East London ICB. All rights reserved. -// --------------------------------------------------------- - -using System; -using System.Collections; -using Xeptions; - -namespace NHSDigital.ApiPlatform.Sdk.Models.Clients.Pds.Exceptions -{ - public class TimeoutPersonalDemographicsServiceClientException : Xeption - { - public TimeoutPersonalDemographicsServiceClientException( - string message, - Exception innerException, - IDictionary data) - : base(message, innerException, data) - { } - } -} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidCareIdentityServiceDependencyException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidCareIdentityServiceDependencyException.cs new file mode 100644 index 0000000..766396b --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/CareIdentityServices/Exceptions/InvalidCareIdentityServiceDependencyException.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions +{ + public class InvalidCareIdentityServiceDependencyException : Xeption + { + public InvalidCareIdentityServiceDependencyException(string message, Exception innerException) + : base(message, innerException) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/InvalidPdsServiceDependencyException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/InvalidPdsServiceDependencyException.cs new file mode 100644 index 0000000..db6441a --- /dev/null +++ b/NHSDigital.ApiPlatform.Sdk/Models/Foundations/Pds/Exceptions/InvalidPdsServiceDependencyException.cs @@ -0,0 +1,16 @@ +// --------------------------------------------------------- +// Copyright (c) North East London ICB. All rights reserved. +// --------------------------------------------------------- + +using System; +using Xeptions; + +namespace NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions +{ + public class InvalidPdsServiceDependencyException : Xeption + { + public InvalidPdsServiceDependencyException(string message, Exception innerException) + : base(message, innerException) + { } + } +} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/TimeoutPdsOrchestrationException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/TimeoutPdsOrchestrationException.cs deleted file mode 100644 index a25e819..0000000 --- a/NHSDigital.ApiPlatform.Sdk/Models/Orchestrations/Pds/Exceptions/TimeoutPdsOrchestrationException.cs +++ /dev/null @@ -1,17 +0,0 @@ -// --------------------------------------------------------- -// Copyright (c) North East London ICB. All rights reserved. -// --------------------------------------------------------- - -using System; -using System.Collections; -using Xeptions; - -namespace NHSDigital.ApiPlatform.Sdk.Models.Orchestrations.Pds.Exceptions -{ - public class TimeoutPdsOrchestrationException : Xeption - { - public TimeoutPdsOrchestrationException(string message, Exception innerException, IDictionary data) - : base(message, innerException, data) - { } - } -} diff --git a/NHSDigital.ApiPlatform.Sdk/Models/Processings/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceProcessingException.cs b/NHSDigital.ApiPlatform.Sdk/Models/Processings/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceProcessingException.cs deleted file mode 100644 index c296424..0000000 --- a/NHSDigital.ApiPlatform.Sdk/Models/Processings/CareIdentityServices/Exceptions/TimeoutCareIdentityServiceProcessingException.cs +++ /dev/null @@ -1,17 +0,0 @@ -// --------------------------------------------------------- -// Copyright (c) North East London ICB. All rights reserved. -// --------------------------------------------------------- - -using System; -using System.Collections; -using Xeptions; - -namespace NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions -{ - public class TimeoutCareIdentityServiceProcessingException : Xeption - { - public TimeoutCareIdentityServiceProcessingException(string message, Exception innerException, IDictionary data) - : base(message, innerException, data) - { } - } -} diff --git a/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj b/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj index 711cdf9..8ebff08 100644 --- a/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj +++ b/NHSDigital.ApiPlatform.Sdk/NHSDigital.ApiPlatform.Sdk.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -19,12 +19,18 @@ git NHSISL; NHS Digital; API; Platform; SDK; Client; .NET; The Standard; - Updated package version to 0.2.0.3. + Release 0.3.0.0 - BREAKING. + Dependency failures are now categorised: a 4xx from CIS2 or PDS raises a + DependencyValidation exception, a 5xx or transport failure raises a Dependency exception, + and a dependency timeout raises a Dependency exception wrapping Timeout*Exception. + Cancellation now propagates to callers as OperationCanceledException instead of being + wrapped. Services take an additional ILoggingBroker constructor argument. + See the 0.3.0.0 release notes for the full upgrade guide. True - 0.2.0.3 - 0.2.0.3 - 0.2.0.3 + 0.3.0.0 + 0.3.0.0 + 0.3.0.0 README.md LICENSE.txt true diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs index 7a12670..6f3313d 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.Exceptions.cs @@ -3,6 +3,7 @@ // --------------------------------------------------------- using System; +using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -71,6 +72,19 @@ private async ValueTask TryCatch( throw await CreateAndLogDependencyExceptionAsync(timeoutCareIdentityServiceException); } + catch (HttpRequestException httpRequestException) + when (IsDependencyValidationStatusCode(httpRequestException.StatusCode)) + { + var invalidCareIdentityServiceDependencyException = + new InvalidCareIdentityServiceDependencyException( + message: "Invalid care identity service dependency error occurred, " + + "fix the errors and try again.", + + innerException: httpRequestException); + + throw await CreateAndLogDependencyValidationExceptionAsync( + invalidCareIdentityServiceDependencyException); + } catch (HttpRequestException httpRequestException) { var failedCareIdentityServiceDependencyException = @@ -147,6 +161,19 @@ private async ValueTask TryCatch( throw await CreateAndLogDependencyExceptionAsync(timeoutCareIdentityServiceException); } + catch (HttpRequestException httpRequestException) + when (IsDependencyValidationStatusCode(httpRequestException.StatusCode)) + { + var invalidCareIdentityServiceDependencyException = + new InvalidCareIdentityServiceDependencyException( + message: "Invalid care identity service dependency error occurred, " + + "fix the errors and try again.", + + innerException: httpRequestException); + + throw await CreateAndLogDependencyValidationExceptionAsync( + invalidCareIdentityServiceDependencyException); + } catch (HttpRequestException httpRequestException) { var failedCareIdentityServiceDependencyException = @@ -195,6 +222,26 @@ private async ValueTask CreateAndLogVali return careIdentityServiceValidationException; } + // A 4xx tells us the dependency rejected what we sent it, which the caller can act on. + // A 5xx (or a transport failure, where StatusCode is null) is not the caller's to fix. + private static bool IsDependencyValidationStatusCode(HttpStatusCode? statusCode) => + statusCode >= HttpStatusCode.BadRequest && statusCode < HttpStatusCode.InternalServerError; + + private async ValueTask + CreateAndLogDependencyValidationExceptionAsync(Xeption exception) + { + var careIdentityServiceDependencyValidationException = + new CareIdentityServiceDependencyValidationException( + message: "Care identity service dependency validation error occurred, " + + "fix the errors and try again.", + + innerException: exception); + + await this.loggingBroker.LogErrorAsync(careIdentityServiceDependencyValidationException); + + return careIdentityServiceDependencyValidationException; + } + private async ValueTask CreateAndLogDependencyExceptionAsync( Xeption exception) { diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs index 1476087..9cb9424 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/CareIdentityServices/CareIdentityService.cs @@ -46,6 +46,7 @@ public CareIdentityService( this.stateBroker = stateBroker; this.tokenBroker = tokenBroker; } + public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs index 3ce4b4e..ced7d33 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Exceptions.cs @@ -3,6 +3,7 @@ // --------------------------------------------------------- using System; +using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -66,6 +67,16 @@ private async ValueTask TryCatch( throw await CreateAndLogDependencyExceptionAsync(timeoutPdsServiceException); } + catch (HttpRequestException httpRequestException) + when (IsDependencyValidationStatusCode(httpRequestException.StatusCode)) + { + var invalidPdsServiceDependencyException = + new InvalidPdsServiceDependencyException( + message: "Invalid PDS service dependency error occurred, fix the errors and try again.", + innerException: httpRequestException); + + throw await CreateAndLogDependencyValidationExceptionAsync(invalidPdsServiceDependencyException); + } catch (HttpRequestException httpRequestException) { var failedPdsServiceDependencyException = @@ -113,6 +124,23 @@ private async ValueTask CreateAndLogValidationExc return pdsServiceValidationException; } + // A 4xx tells us the dependency rejected what we sent it, which the caller can act on. + // A 5xx (or a transport failure, where StatusCode is null) is not the caller's to fix. + private static bool IsDependencyValidationStatusCode(HttpStatusCode? statusCode) => + statusCode >= HttpStatusCode.BadRequest && statusCode < HttpStatusCode.InternalServerError; + + private async ValueTask + CreateAndLogDependencyValidationExceptionAsync(Xeption exception) + { + var pdsServiceDependencyValidationException = new PdsServiceDependencyValidationException( + message: "PDS service dependency validation error occurred, fix the errors and try again.", + innerException: exception); + + await this.loggingBroker.LogErrorAsync(pdsServiceDependencyValidationException); + + return pdsServiceDependencyValidationException; + } + private async ValueTask CreateAndLogDependencyExceptionAsync( Xeption exception) { diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs index 7052717..0d8b930 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.cs @@ -50,7 +50,7 @@ public ValueTask SearchPatientsAsync( if (!string.IsNullOrWhiteSpace(searchCriteria.NhsNumber)) { - url = $"{baseUrl}/Patient/{searchCriteria.NhsNumber}"; + url = $"{baseUrl}/Patient/{Uri.EscapeDataString(searchCriteria.NhsNumber)}"; } else { diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs index a4c5596..38b2f73 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.Exceptions.cs @@ -3,7 +3,6 @@ // --------------------------------------------------------- using System; -using System.Threading; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; @@ -16,19 +15,12 @@ internal sealed partial class PdsOrchestrationService { private delegate ValueTask ReturningStringFunction(); - private async ValueTask TryCatch( - ReturningStringFunction returningStringFunction, - CancellationToken cancellationToken) + private async ValueTask TryCatch(ReturningStringFunction returningStringFunction) { try { return await returningStringFunction(); } - catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested is false) - { - throw await CreateAndLogTimeoutDependencyExceptionAsync(); - } catch (OperationCanceledException) { throw; @@ -93,16 +85,6 @@ private async ValueTask TryCatch( { throw await CreateAndLogDependencyExceptionAsync(pdsServiceException); } - catch (TimeoutException timeoutException) - { - var timeoutPdsOrchestrationException = - new TimeoutPdsOrchestrationException( - message: "Failed PDS orchestration timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - throw await CreateAndLogTimeoutExceptionAsync(timeoutPdsOrchestrationException); - } catch (Exception exception) { var failedPdsOrchestrationException = @@ -115,33 +97,6 @@ private async ValueTask TryCatch( } } - private async ValueTask CreateAndLogTimeoutDependencyExceptionAsync() - { - var timeoutException = - new TimeoutException("The dependency operation timed out."); - - var timeoutPdsOrchestrationException = - new TimeoutPdsOrchestrationException( - message: "Failed PDS orchestration timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - return await CreateAndLogTimeoutExceptionAsync(timeoutPdsOrchestrationException); - } - - private async ValueTask CreateAndLogTimeoutExceptionAsync( - Xeption exception) - { - var pdsOrchestrationDependencyException = - new PdsOrchestrationDependencyException( - message: "PDS orchestration dependency error occurred, fix the errors and try again.", - innerException: exception); - - await this.loggingBroker.LogErrorAsync(pdsOrchestrationDependencyException); - - return pdsOrchestrationDependencyException; - } - private async ValueTask CreateAndLogValidationExceptionAsync( Xeption exception) { @@ -173,7 +128,7 @@ private async ValueTask CreateAndLogDepende { var pdsOrchestrationDependencyException = new PdsOrchestrationDependencyException( - message: "PDS orchestration dependency error occurred, fix the errors and try again.", + message: "PDS orchestration dependency error occurred, please contact support.", innerException: exception.InnerException as Xeption); await this.loggingBroker.LogErrorAsync(pdsOrchestrationDependencyException); diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs index 87240ad..f17f0e9 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Orchestrations/Pds/PdsOrchestrationService.cs @@ -38,6 +38,6 @@ public ValueTask SearchPatientsAsync( return await this.pdsService .SearchPatientsAsync(accessToken, searchCriteria, cancellationToken); - }, cancellationToken); + }); } } diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs index a30750d..5839e0d 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.Exceptions.cs @@ -3,7 +3,6 @@ // --------------------------------------------------------- using System; -using System.Threading; using System.Threading.Tasks; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.CareIdentityServices.Exceptions; using NHSDigital.ApiPlatform.Sdk.Models.Processings.CareIdentityServices.Exceptions; @@ -16,19 +15,12 @@ internal partial class CareIdentityServiceProcessingService : ICareIdentityServi private delegate ValueTask ReturningTaskFunction(); private delegate ValueTask ReturningNothingFunction(); - private async ValueTask TryCatch( - ReturningTaskFunction returningTaskFunction, - CancellationToken cancellationToken) + private async ValueTask TryCatch(ReturningTaskFunction returningTaskFunction) { try { return await returningTaskFunction(); } - catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested is false) - { - throw await CreateAndLogTimeoutDependencyExceptionAsync(); - } catch (OperationCanceledException) { throw; @@ -79,16 +71,6 @@ private async ValueTask TryCatch( { throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceServiceException); } - catch (TimeoutException timeoutException) - { - var timeoutCareIdentityServiceProcessingException = - new TimeoutCareIdentityServiceProcessingException( - message: "Failed care identity service processing timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - throw await CreateAndLogTimeoutExceptionAsync(timeoutCareIdentityServiceProcessingException); - } catch (Exception exception) { var failedCareIdentityServiceProcessingException = @@ -101,19 +83,12 @@ private async ValueTask TryCatch( } } - private async ValueTask TryCatch( - ReturningNothingFunction returningNothingFunction, - CancellationToken cancellationToken) + private async ValueTask TryCatch(ReturningNothingFunction returningNothingFunction) { try { await returningNothingFunction(); } - catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested is false) - { - throw await CreateAndLogTimeoutDependencyExceptionAsync(); - } catch (OperationCanceledException) { throw; @@ -164,16 +139,6 @@ private async ValueTask TryCatch( { throw await CreateAndLogDependencyExceptionAsync(careIdentityServiceServiceException); } - catch (TimeoutException timeoutException) - { - var timeoutCareIdentityServiceProcessingException = - new TimeoutCareIdentityServiceProcessingException( - message: "Failed care identity service processing timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - throw await CreateAndLogTimeoutExceptionAsync(timeoutCareIdentityServiceProcessingException); - } catch (Exception exception) { var failedCareIdentityServiceProcessingException = @@ -186,34 +151,6 @@ private async ValueTask TryCatch( } } - private async ValueTask - CreateAndLogTimeoutDependencyExceptionAsync() - { - var timeoutException = - new TimeoutException("The dependency operation timed out."); - - var timeoutCareIdentityServiceProcessingException = - new TimeoutCareIdentityServiceProcessingException( - message: "Failed care identity service processing timeout error occurred, contact support.", - innerException: timeoutException, - data: timeoutException.Data); - - return await CreateAndLogTimeoutExceptionAsync(timeoutCareIdentityServiceProcessingException); - } - - private async ValueTask CreateAndLogTimeoutExceptionAsync( - Xeption exception) - { - var careIdentityServiceProcessingDependencyException = - new CareIdentityServiceProcessingDependencyException( - message: "Care identity service processing dependency error occurred, please contact support.", - innerException: exception); - - await this.loggingBroker.LogErrorAsync(careIdentityServiceProcessingDependencyException); - - return careIdentityServiceProcessingDependencyException; - } - private async ValueTask CreateAndLogValidationExceptionAsync(Xeption exception) { diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs index 9989924..938e639 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingService.cs @@ -29,14 +29,14 @@ public ValueTask BuildLoginUrlAsync(CancellationToken cancellationToken cancellationToken.ThrowIfCancellationRequested(); return await this.careIdentityService.BuildLoginUrlAsync(cancellationToken); - }, cancellationToken); + }); public ValueTask LogoutAsync(CancellationToken cancellationToken = default) => TryCatch(async () => { cancellationToken.ThrowIfCancellationRequested(); await this.careIdentityService.LogoutAsync(cancellationToken); - }, cancellationToken); + }); public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken = default) => TryCatch(async () => @@ -44,7 +44,7 @@ public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken cancellationToken.ThrowIfCancellationRequested(); return await this.careIdentityService.GetAccessTokenAsync(cancellationToken); - }, cancellationToken); + }); public ValueTask GetUserInfoAsync( string code, @@ -62,6 +62,6 @@ public ValueTask GetUserInfoAsync( .GetUserInfoAsync(accessToken, cancellationToken); return userInfo; - }, cancellationToken); + }); } } From 41fee8031d141feb2b282f7a322bc897cdb1155c Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 17:26:45 +0100 Subject: [PATCH 13/17] CONFIG: Propagate Test Exit Codes And Bump Package Version To 0.3.0.0 --- .github/workflows/build.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 75e668b..56c2805 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,22 +27,33 @@ jobs: - name: Build run: dotnet build --no-restore - name: Run Unit Tests - run: >- + run: | $projects = Get-ChildItem -Path . -Filter "*Tests.Unit*.csproj" -Recurse - + $failedProjects = @() foreach ($project in $projects) { Write-Host "Running tests for: $($project.FullName)" dotnet test $project.FullName --no-build --verbosity normal + if ($LASTEXITCODE -ne 0) { $failedProjects += $project.Name } + } + if ($failedProjects.Count -gt 0) { + Write-Host "::error::Test failures in: $($failedProjects -join ', ')" + exit 1 } shell: pwsh - name: Run Acceptance Tests - run: >- + run: | $projects = Get-ChildItem -Path . -Filter "*Tests.Acceptance*.csproj" -Recurse - + $failedProjects = @() foreach ($project in $projects) { Write-Host "Running tests for: $($project.FullName)" dotnet test $project.FullName --no-build --verbosity normal + if ($LASTEXITCODE -ne 0) { $failedProjects += $project.Name } + } + if ($failedProjects.Count -gt 0) { + Write-Host "::error::Test failures in: $($failedProjects -join ', ')" + exit 1 } + shell: pwsh add_tag: name: Tag and Release runs-on: ubuntu-latest From 0be0546a0851fe20b77d7d5566392262e172ee4f Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 17:26:45 +0100 Subject: [PATCH 14/17] DOCUMENTATION: Regenerate Dependency Graph For The Logging Broker --- Documentation/DependencyGraph/README.md | 14 ++++++++++---- Documentation/DependencyGraph/graph-data.js | 18 ++++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Documentation/DependencyGraph/README.md b/Documentation/DependencyGraph/README.md index 25ab55e..af00664 100644 --- a/Documentation/DependencyGraph/README.md +++ b/Documentation/DependencyGraph/README.md @@ -51,7 +51,7 @@ view you were on, and switching carries your current selection across. At the last scan, 27 declared components and 84 declared edges draw as **25 components · 79 flows** in the single-copy view and **100 nodes · -413 flows** per consumer (27 · 84 and 113 · 441 with utility brokers on). +413 flows** per consumer (29 · 86 and 115 · 443 with utility brokers on). `.github/workflows/pages.yml` publishes this folder to GitHub Pages on every push to `main` that touches it — `index.html` is the site root. Nothing is @@ -71,9 +71,15 @@ enabled once in the repository's Settings → Pages (source: GitHub Actions). `IApiPlatformClient` holding the same two sub-clients, but nothing constructs or registers it: `AddApiPlatformSdkCore` registers a hand-built `ApiPlatformClient` instead. It shows on the graph with no inbound flows. -- **`PdsOrchestrationService` takes `IApiPlatformTokenBroker` and never uses - it.** The access token comes from `CareIdentityService.GetAccessTokenAsync`; - the injected broker is unused. +- **`LoggingBroker` has no inbound flows on this graph, by design.** Every + service takes `ILoggingBroker`, but it is only ever reached from the + `CreateAndLog*` exception factories, and this graph draws happy-path calls + only. It is a utility broker, so it is hidden behind the toggle along with + the DateTime and Identifier brokers. +- **Dependency failures are categorised by HTTP status.** Both foundation + services split `HttpRequestException`: a 4xx becomes a + `*DependencyValidationException` (the caller sent something the dependency + rejected), a 5xx or a transport failure becomes a `*DependencyException`. - **The storage brokers are the extension seam.** `IApiPlatformStateBroker` and `IApiPlatformTokenBroker` each have an in-memory implementation in the Sdk and a session-backed one in Sdk.AspNetCore. Both are registered with diff --git a/Documentation/DependencyGraph/graph-data.js b/Documentation/DependencyGraph/graph-data.js index d6f51d8..ec5b602 100644 --- a/Documentation/DependencyGraph/graph-data.js +++ b/Documentation/DependencyGraph/graph-data.js @@ -35,6 +35,7 @@ { id: "ext-aspnetcore", name: "ASP.NET Core", kind: "external" }, { id: "ext-bcl", name: ".NET base class library", kind: "external" }, { id: "ext-adotnet", name: "ADotNet", kind: "external" }, + { id: "ext-logging", name: "Microsoft.Extensions.Logging", kind: "external" }, { id: "ext-nhs", name: "NHS Digital API Platform (remote)", kind: "external" }, ]; @@ -69,6 +70,9 @@ description: "ASP.NET Core session state. The Sdk.AspNetCore brokers throw when there is no HttpContext or session — the host must have called UseSession()." }); C({ id: "EXT.Bcl", name: "System.Security.Cryptography / Text.Json", project: "ext-bcl", layer: "external", col: 8, shared: true, methods: [], description: "RandomNumberGenerator for the CSRF state, System.Text.Json (Web defaults) for payloads, Guid.NewGuid for the PDS X-Request-ID, DateTimeOffset.UtcNow for token expiry." }); + C({ id: "EXT.Logging", name: "Microsoft.Extensions.Logging", project: "ext-logging", layer: "external", col: 8, + shared: true, utility: true, methods: [], + description: "AddApiPlatformSdkCore calls AddLogging(), so a host that configures no providers still resolves an ILoggerFactory and the SDK's error logging goes nowhere rather than failing." }); C({ id: "EXT.Cis2", name: "NHS CIS2 (Care Identity Service)", project: "ext-nhs", layer: "external", col: 8, shared: true, methods: [], description: "OAuth2 authorization-code flow without PKCE — CIS2 does not support it. Auth, token and userinfo endpoints come from CareIdentityConfigurations." }); C({ id: "EXT.Pds", name: "NHS Personal Demographics Service", project: "ext-nhs", layer: "external", col: 8, shared: true, methods: [], @@ -121,7 +125,7 @@ ================================================================== */ C({ id: "PDS.Orchestration", name: "PdsOrchestrationService", project: "sdk", layer: "orchestration", col: 3, methods: ["SearchPatientsAsync"], - description: "Gets a CIS2 access token, refuses the call with UnauthorizedPdsOrchestrationException when it comes back empty, then hands it to PdsService. NOTE: it also takes IApiPlatformTokenBroker in its constructor but never calls it — the token comes from CareIdentityService." }); + description: "Validates the search criteria, gets a CIS2 access token, refuses the call with UnauthorizedPdsOrchestrationException when it comes back empty, then hands it to PdsService." }); D(["PDS.Orchestration", "SearchPatientsAsync"], ["CIS.Foundation", "GetAccessTokenAsync"]); D(["PDS.Orchestration", "SearchPatientsAsync"], ["PDS.Foundation", "SearchPatientsAsync"]); @@ -196,6 +200,12 @@ methods: ["GetNewGuid"] }); D(["IdentifierBroker", "GetNewGuid"], ["EXT.Bcl", "Guid.NewGuid"]); + C({ id: "LoggingBroker", name: "LoggingBroker", project: "sdk", layer: "broker", col: 5, utility: true, + methods: ["LogErrorAsync", "LogCriticalAsync"], + description: "Wraps ILogger. Every service takes it, but it is only ever reached from the CreateAndLog* exception factories — which this graph deliberately does not draw — so it has no inbound flows here. Registered with TryAddSingleton so a host can substitute its own." }); + D(["LoggingBroker", "LogErrorAsync"], ["EXT.Logging", "ILogger.LogError"]); + D(["LoggingBroker", "LogCriticalAsync"], ["EXT.Logging", "ILogger.LogCritical"]); + /* -- the two swappable storage brokers ------------------------------- Both interfaces have an in-memory implementation shipped in the Sdk and a session-backed one in Sdk.AspNetCore. Which one you get is a @@ -262,14 +272,14 @@ "CIS.Client", "PDS.Client", "CIS.Processing", "PDS.Orchestration", "CIS.Foundation", "PDS.Foundation", - "HttpBroker", "CryptoBroker", "JsonBroker", "DateTimeBroker", "IdentifierBroker", + "HttpBroker", "CryptoBroker", "JsonBroker", "DateTimeBroker", "IdentifierBroker", "LoggingBroker", "StateBroker", "TokenBroker", "MemoryStateBroker", "MemoryTokenBroker", // NHSDigital.ApiPlatform.Sdk.AspNetCore "SessionStateBroker", "SessionTokenBroker", // NHSDigital.ApiPlatform.Infrastructure "INF.Program", "INF.ScriptGeneration", // externals - "EXT.HttpClientFactory", "EXT.Session", "EXT.Bcl", "EXT.Cis2", "EXT.Pds", "EXT.ADotNet", + "EXT.HttpClientFactory", "EXT.Session", "EXT.Bcl", "EXT.Logging", "EXT.Cis2", "EXT.Pds", "EXT.ADotNet", ); /* ------------------------------------------------------------------ @@ -277,7 +287,7 @@ Derive their method rows from the declared edges so the rows and the arrows can never drift apart. ------------------------------------------------------------------ */ - for (const extId of ["EXT.HttpClientFactory", "EXT.Session", "EXT.Bcl", "EXT.Cis2", "EXT.Pds", "EXT.ADotNet"]) { + for (const extId of ["EXT.HttpClientFactory", "EXT.Session", "EXT.Bcl", "EXT.Logging", "EXT.Cis2", "EXT.Pds", "EXT.ADotNet"]) { const comp = components.find(c => c.id === extId); const called = []; for (const e of edges) { From 4a8f563093ab67c446c347733186051b14f04e84 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 18:07:05 +0100 Subject: [PATCH 15/17] SdkVerificationRemediationTests -> FAIL --- .../PersonalDemographicsServiceClientTests.cs | 4 +- ...entityServiceTests.Logic.GetAccessToken.cs | 56 ++++++++ ...dsServiceTests.Contracts.SearchPatients.cs | 120 ++++++++++++------ .../Foundations/Pds/PdsServiceTests.cs | 5 +- ...nServiceTests.Exceptions.SearchPatients.cs | 40 ------ .../Pds/PdsOrchestrationServiceTests.cs | 2 +- ...erviceProcessingServiceTests.Exceptions.cs | 31 ----- 7 files changed, 146 insertions(+), 112 deletions(-) diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs index e5aa138..5c8d2be 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Clients/PersonalDemographicsServices/PersonalDemographicsServiceClientTests.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -78,7 +78,7 @@ public static TheoryData UnexpectedExceptions() => private static SearchCriteria CreateRandomSearchCriteria() => new SearchCriteria { - NhsNumber = GetRandomString() + NhsNumber = new IntRange(min: 1000000000, max: 1999999999).GetValue().ToString() }; private static string GetRandomString() => diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs index 936b716..db42d10 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/CareIdentityServices/CareIdentityServiceTests.Logic.GetAccessToken.cs @@ -223,5 +223,61 @@ private void SetupExpiredAccessTokenWithValidRefreshToken( broker.Deserialize(randomTokenJson)) .Returns(refreshedTokenResult); } + + [Fact] + public async Task ShouldStoreTheRotatedRefreshTokenOnGetAccessTokenAsync() + { + // given + // CIS2 rotates the refresh token on every refresh. If the SDK fails to store the new one, + // the next refresh presents a spent token and the user is silently signed out. + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + + DateTimeOffset expectedRefreshExpiresAtUtc = + randomDateTimeOffset.AddSeconds(int.Parse(randomTokenResult.RefreshTokenExpiresIn)); + + SetupExpiredAccessTokenWithValidRefreshToken( + randomRefreshToken, + randomDateTimeOffset, + randomTokenResult); + + // when + await this.careIdentityService.GetAccessTokenAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreRefreshTokenAsync( + randomTokenResult.RefreshToken, + expectedRefreshExpiresAtUtc, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ShouldNotStoreARefreshTokenOnGetAccessTokenIfTheRefreshDidNotReturnOneAsync() + { + // given + string randomRefreshToken = GetRandomString(); + DateTimeOffset randomDateTimeOffset = GetRandomDateTimeOffset(); + TokenResult randomTokenResult = CreateRandomTokenResult(); + randomTokenResult.RefreshToken = string.Empty; + + SetupExpiredAccessTokenWithValidRefreshToken( + randomRefreshToken, + randomDateTimeOffset, + randomTokenResult); + + // when + await this.careIdentityService.GetAccessTokenAsync(); + + // then + this.tokenBrokerMock.Verify(broker => + broker.StoreRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs index f064162..6d44266 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.Contracts.SearchPatients.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- @@ -17,41 +17,6 @@ namespace NHSDigital.ApiPlatform.Sdk.Tests.Unit.Services.Foundations.Pds { public partial class PdsServiceTests { - [Fact] - public async Task ShouldEscapeTheNhsNumberOnSearchPatientsSoItCannotEscapeThePatientPathAsync() - { - // given - string randomAccessToken = GetRandomString(); - string actualUrl = null; - - // A caller-supplied NHS number must never be able to steer the request somewhere else on the - // authenticated host, nor bolt extra FHIR query parameters onto it. - var traversalSearchCriteria = new SearchCriteria - { - NhsNumber = "../../Practitioner/1?scope=all" - }; - - this.httpBrokerMock.Setup(broker => - broker.GetAsync( - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Callback, CancellationToken>( - (url, configureRequest, cancellationToken) => actualUrl = url) - .ReturnsAsync(CreateHttpResponse(GetRandomString())); - - // when - await this.pdsService.SearchPatientsAsync(randomAccessToken, traversalSearchCriteria); - - // then - string expectedBaseUrl = - $"{this.apiPlatformConfigurations.PersonalDemographicsService.BaseUrl}/Patient/"; - - actualUrl.Should().StartWith(expectedBaseUrl); - actualUrl.Should().NotContain("/Practitioner"); - actualUrl.Should().NotContain("?"); - actualUrl.Substring(expectedBaseUrl.Length).Should().NotContain("/"); - } [Fact] public async Task ShouldEscapeDemographicSearchValuesOnSearchPatientsAsync() @@ -64,7 +29,8 @@ public async Task ShouldEscapeDemographicSearchValuesOnSearchPatientsAsync() { Surname = "O'Brien & Sons", FirstName = "Anne Marie", - Gender = "female", + Gender = "female/other", + DateOfBirth = "1980-01-01 00:00", Postcode = "E1 6AN" }; @@ -83,11 +49,16 @@ public async Task ShouldEscapeDemographicSearchValuesOnSearchPatientsAsync() // then actualUrl.Should().Contain($"family={Uri.EscapeDataString("O'Brien & Sons")}"); actualUrl.Should().Contain($"given={Uri.EscapeDataString("Anne Marie")}"); + actualUrl.Should().Contain($"gender={Uri.EscapeDataString("female/other")}"); + actualUrl.Should().Contain($"birthdate=eq{Uri.EscapeDataString("1980-01-01 00:00")}"); actualUrl.Should().Contain($"address-postalcode={Uri.EscapeDataString("E1 6AN")}"); // The raw ampersand would otherwise have introduced a query parameter of its own. actualUrl.Should().NotContain("& Sons"); actualUrl.Should().NotContain("Anne Marie"); + + // An unescaped "/" in gender would have introduced a path segment. + actualUrl.Should().NotContain("female/other"); } [Theory] @@ -156,5 +127,80 @@ await Assert.ThrowsAsync( // then actualException.InnerException.Should().BeOfType(); } + + [Theory] + [InlineData("..")] + [InlineData(".")] + [InlineData("../../Practitioner/1")] + [InlineData("12345")] + [InlineData("abcdefghij")] + [InlineData("123456789 ")] + public async Task ShouldThrowValidationExceptionOnSearchPatientsIfNhsNumberIsNotTenDigitsAsync( + string invalidNhsNumber) + { + // given + string randomAccessToken = GetRandomString(); + var searchCriteria = new SearchCriteria { NhsNumber = invalidNhsNumber }; + + var invalidArgumentPdsServiceException = + new InvalidArgumentPdsServiceException( + message: "Invalid argument(s), please correct the errors and try again."); + + invalidArgumentPdsServiceException.UpsertDataList( + key: "searchCriteria.NhsNumber", + value: "NHS number must be 10 digits"); + + var expectedPdsServiceValidationException = + new PdsServiceValidationException( + message: "PDS service validation error occurred, please fix the errors and try again.", + innerException: invalidArgumentPdsServiceException); + + // when + ValueTask searchPatientsTask = + this.pdsService.SearchPatientsAsync(randomAccessToken, searchCriteria); + + PdsServiceValidationException actualException = + await Assert.ThrowsAsync(async () => await searchPatientsTask); + + // then + actualException.Should().BeEquivalentTo(expectedPdsServiceValidationException); + + this.httpBrokerMock.Verify(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ShouldAssertTheDependencyValidationMessageOnSearchPatientsAsync() + { + // given + string randomAccessToken = GetRandomString(); + SearchCriteria randomSearchCriteria = CreateRandomSearchCriteriaWithNhsNumber(); + + this.httpBrokerMock.Setup(broker => + broker.GetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(GetRandomString()) + }); + + // when + PdsServiceDependencyValidationException actualException = + await Assert.ThrowsAsync(async () => + await this.pdsService.SearchPatientsAsync(randomAccessToken, randomSearchCriteria)); + + // then + actualException.Message + .Should().Be("PDS service dependency validation error occurred, fix the errors and try again."); + + actualException.InnerException.Message + .Should().Be("Invalid PDS service dependency error occurred, fix the errors and try again."); + } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs index 1ed5ad0..d4af66c 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Foundations/Pds/PdsServiceTests.cs @@ -79,7 +79,7 @@ public static TheoryData InvalidTexts() => private static SearchCriteria CreateRandomSearchCriteriaWithNhsNumber() => new SearchCriteria { - NhsNumber = GetRandomString() + NhsNumber = GetRandomNhsNumber() }; private static SearchCriteria CreateRandomSearchCriteriaWithDemographics() => @@ -95,6 +95,9 @@ private static SearchCriteria CreateRandomSearchCriteriaWithDemographics() => private static Expression> SameExceptionAs(Xeption expectedException) => actualException => (actualException as Xeption).SameExceptionAs(expectedException); + private static string GetRandomNhsNumber() => + new IntRange(min: 1000000000, max: 1999999999).GetValue().ToString(); + private static string GetRandomString() => new MnemonicString(wordCount: 1, wordMinLength: 8, wordMaxLength: 12).GetValue(); diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs index b26bee1..a6154e1 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.Exceptions.SearchPatients.cs @@ -114,45 +114,5 @@ await Assert.ThrowsAsync( Times.Once); } - [Fact] - public async Task ShouldThrowDependencyExceptionOnSearchPatientsIfTimeoutOccursInFoundationServiceAsync() - { - // given - SearchCriteria randomSearchCriteria = CreateRandomSearchCriteria(); - var timeoutException = new TimeoutException(); - - var failedPdsServiceDependencyException = - new NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions - .FailedPdsServiceDependencyException( - message: "Failed PDS service dependency error occurred, please contact support.", - innerException: timeoutException); - - var pdsServiceDependencyException = - new NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions.PdsServiceDependencyException( - message: "PDS service dependency error occurred, please contact support.", - innerException: failedPdsServiceDependencyException); - - this.careIdentityServiceMock.Setup(service => - service.GetAccessTokenAsync(It.IsAny())) - .ReturnsAsync(GetRandomString()); - - this.pdsServiceMock.Setup(service => - service.SearchPatientsAsync( - It.IsAny(), - It.IsAny(), - It.IsAny())) - .ThrowsAsync(pdsServiceDependencyException); - - // when - ValueTask searchPatientsTask = - this.pdsOrchestrationService.SearchPatientsAsync(randomSearchCriteria); - - PdsOrchestrationDependencyException actualException = - await Assert.ThrowsAsync( - async () => await searchPatientsTask); - - // then - actualException.InnerException.InnerException.Should().BeOfType(); - } } } diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs index ddef9d6..bc1917d 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Orchestrations/Pds/PdsOrchestrationServiceTests.cs @@ -75,7 +75,7 @@ public static TheoryData ServiceExceptions() => private static SearchCriteria CreateRandomSearchCriteria() => new SearchCriteria { - NhsNumber = GetRandomString() + NhsNumber = new IntRange(min: 1000000000, max: 1999999999).GetValue().ToString() }; private static Expression> SameExceptionAs(Xeption expectedException) => diff --git a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs index 9f77620..c7f55fb 100644 --- a/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs +++ b/NHSDigital.ApiPlatform.Sdk.Tests.Unit/Services/Processings/CareIdentityServices/CareIdentityServiceProcessingServiceTests.Exceptions.cs @@ -166,36 +166,5 @@ await Assert.ThrowsAsync( Times.Once); } - [Fact] - public async Task ShouldThrowDependencyExceptionOnGetAccessTokenIfTimeoutOccursInFoundationServiceAsync() - { - // given - var timeoutException = new TimeoutException(); - - var failedCareIdentityServiceDependencyException = - new FailedCareIdentityServiceDependencyException( - message: "Failed care identity service dependency error occurred, please contact support.", - innerException: timeoutException); - - var careIdentityServiceDependencyException = - new CareIdentityServiceDependencyException( - message: "Care identity service dependency error occurred, please contact support.", - innerException: failedCareIdentityServiceDependencyException); - - this.careIdentityServiceMock.Setup(service => - service.GetAccessTokenAsync(It.IsAny())) - .ThrowsAsync(careIdentityServiceDependencyException); - - // when - ValueTask getAccessTokenTask = - this.careIdentityServiceProcessingService.GetAccessTokenAsync(); - - CareIdentityServiceProcessingDependencyException actualException = - await Assert.ThrowsAsync( - async () => await getAccessTokenTask); - - // then - actualException.InnerException.InnerException.Should().BeOfType(); - } } } From faedd46f3df42047fdcae3b502e05c21b768cea8 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 18:07:05 +0100 Subject: [PATCH 16/17] SdkVerificationRemediationTests -> PASS --- .../Foundations/Pds/PdsService.Validations.cs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs index 788b113..0857cd4 100644 --- a/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs +++ b/NHSDigital.ApiPlatform.Sdk/Services/Foundations/Pds/PdsService.Validations.cs @@ -1,8 +1,9 @@ -// --------------------------------------------------------- +// --------------------------------------------------------- // Copyright (c) North East London ICB. All rights reserved. // --------------------------------------------------------- using System; +using System.Text.RegularExpressions; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds; using NHSDigital.ApiPlatform.Sdk.Models.Foundations.Pds.Exceptions; using Xeptions; @@ -11,6 +12,8 @@ namespace NHSDigital.ApiPlatform.Sdk.Services.Foundations.Pds { internal partial class PdsService : IPdsService { + private static readonly Regex NhsNumberPattern = new Regex(@"^\d{10}$", RegexOptions.Compiled); + public void ValidateOnSearchPatients(string accessToken, SearchCriteria searchCriteria) { ValidateSearchCriteriaIsNotNull(searchCriteria); @@ -20,7 +23,8 @@ public void ValidateOnSearchPatients(string accessToken, SearchCriteria searchCr message: "Invalid argument(s), please correct the errors and try again."), (Rule: IsInvalid(accessToken), Parameter: nameof(accessToken)), - (Rule: IsInvalidSearchCriteria(searchCriteria), Parameter: nameof(searchCriteria))); + (Rule: IsInvalidSearchCriteria(searchCriteria), Parameter: nameof(searchCriteria)), + (Rule: IsInvalidNhsNumber(searchCriteria.NhsNumber), Parameter: "searchCriteria.NhsNumber")); } private static void ValidateSearchCriteriaIsNotNull(SearchCriteria searchCriteria) @@ -38,6 +42,19 @@ private static void ValidateSearchCriteriaIsNotNull(SearchCriteria searchCriteri Message = "Text is required" }; + // An NHS number goes into the request PATH, so escaping alone is not enough: "." and ".." are + // unreserved characters that survive Uri.EscapeDataString, and Uri parsing then collapses the + // dot segments, silently moving the request off /Patient. Constraining it to the 10 digits an + // NHS number actually is closes that off at the source. + private static dynamic IsInvalidNhsNumber(string nhsNumber) => new + { + Condition = + string.IsNullOrWhiteSpace(nhsNumber) is false && + NhsNumberPattern.IsMatch(nhsNumber) is false, + + Message = "NHS number must be 10 digits" + }; + private static dynamic IsInvalidSearchCriteria(SearchCriteria searchCriteria) => new { Condition = From dba50a2e4fbe1135e3b6eee07b6092b16498f8f3 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Tue, 11 Aug 2026 18:07:05 +0100 Subject: [PATCH 17/17] INFRA: Generate The Corrected Test Steps And Add The Integration Test Step --- .github/workflows/build.yml | 42 ++++++++++++++- Documentation/DependencyGraph/README.md | 4 +- .../Services/ScriptGenerationService.cs | 51 +++++++++++++------ 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 56c2805..49e7bf2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,28 +27,66 @@ jobs: - name: Build run: dotnet build --no-restore - name: Run Unit Tests - run: | + run: >- $projects = Get-ChildItem -Path . -Filter "*Tests.Unit*.csproj" -Recurse + + if ($projects.Count -eq 0) { + Write-Host "::error::No test projects matched *Tests.Unit*.csproj" + exit 1 + } + $failedProjects = @() + foreach ($project in $projects) { Write-Host "Running tests for: $($project.FullName)" dotnet test $project.FullName --no-build --verbosity normal if ($LASTEXITCODE -ne 0) { $failedProjects += $project.Name } } + if ($failedProjects.Count -gt 0) { Write-Host "::error::Test failures in: $($failedProjects -join ', ')" exit 1 } shell: pwsh - name: Run Acceptance Tests - run: | + run: >- $projects = Get-ChildItem -Path . -Filter "*Tests.Acceptance*.csproj" -Recurse + + if ($projects.Count -eq 0) { + Write-Host "::error::No test projects matched *Tests.Acceptance*.csproj" + exit 1 + } + + $failedProjects = @() + + foreach ($project in $projects) { + Write-Host "Running tests for: $($project.FullName)" + dotnet test $project.FullName --no-build --verbosity normal + if ($LASTEXITCODE -ne 0) { $failedProjects += $project.Name } + } + + if ($failedProjects.Count -gt 0) { + Write-Host "::error::Test failures in: $($failedProjects -join ', ')" + exit 1 + } + shell: pwsh + - name: Run Integration Tests + run: >- + $projects = Get-ChildItem -Path . -Filter "*Tests.Integration*.csproj" -Recurse + + if ($projects.Count -eq 0) { + Write-Host "::error::No test projects matched *Tests.Integration*.csproj" + exit 1 + } + $failedProjects = @() + foreach ($project in $projects) { Write-Host "Running tests for: $($project.FullName)" dotnet test $project.FullName --no-build --verbosity normal if ($LASTEXITCODE -ne 0) { $failedProjects += $project.Name } } + if ($failedProjects.Count -gt 0) { Write-Host "::error::Test failures in: $($failedProjects -join ', ')" exit 1 diff --git a/Documentation/DependencyGraph/README.md b/Documentation/DependencyGraph/README.md index af00664..d738540 100644 --- a/Documentation/DependencyGraph/README.md +++ b/Documentation/DependencyGraph/README.md @@ -46,10 +46,10 @@ view you were on, and switching carries your current selection across. - Whatever is selected is outlined and lettered in **amber**; rows the traced path passes through carry a faint blue tint. Click the background or Reset to clear. Search finds components and methods. The **utility brokers** - toggle reveals the DateTime / Identifier broker copies that are hidden by + toggle reveals the DateTime / Identifier / Logging broker copies and the logging external that are hidden by default for readability. -At the last scan, 27 declared components and 84 declared edges draw as +At the last scan, 29 declared components and 86 declared edges draw as **25 components · 79 flows** in the single-copy view and **100 nodes · 413 flows** per consumer (29 · 86 and 115 · 443 with utility brokers on). diff --git a/NHSDigital.ApiPlatform.Infrastructure/Services/ScriptGenerationService.cs b/NHSDigital.ApiPlatform.Infrastructure/Services/ScriptGenerationService.cs index cdb60a7..7beab6a 100644 --- a/NHSDigital.ApiPlatform.Infrastructure/Services/ScriptGenerationService.cs +++ b/NHSDigital.ApiPlatform.Infrastructure/Services/ScriptGenerationService.cs @@ -80,27 +80,21 @@ public void GenerateBuildScript(string branchName, string projectName, string do { Name = "Run Unit Tests", Shell = "pwsh", - Run = - """ - $projects = Get-ChildItem -Path . -Filter "*Tests.Unit*.csproj" -Recurse - foreach ($project in $projects) { - Write-Host "Running tests for: $($project.FullName)" - dotnet test $project.FullName --no-build --verbosity normal - } - """ + Run = CreateTestRunScript("*Tests.Unit*.csproj") }, new TestTask { Name = "Run Acceptance Tests", - Run = - """ - $projects = Get-ChildItem -Path . -Filter "*Tests.Acceptance*.csproj" -Recurse - foreach ($project in $projects) { - Write-Host "Running tests for: $($project.FullName)" - dotnet test $project.FullName --no-build --verbosity normal - } - """ + Shell = "pwsh", + Run = CreateTestRunScript("*Tests.Acceptance*.csproj") + }, + + new TestTask + { + Name = "Run Integration Tests", + Shell = "pwsh", + Run = CreateTestRunScript("*Tests.Integration*.csproj") } } } @@ -197,5 +191,30 @@ public void GeneratePrLintScript(string branchName) adoPipeline: githubPipeline, path: buildScriptPath); } + + /// + /// Runs every test project matching and fails the step if ANY of + /// them failed. A bare loop reports only the last project's exit code, which silently hides a + /// failure in every project but the last. An empty match is also a failure — it means the glob has + /// drifted away from the projects it was meant to cover. + /// + private static string CreateTestRunScript(string projectFilter) => + $$""" + $projects = Get-ChildItem -Path . -Filter "{{projectFilter}}" -Recurse + if ($projects.Count -eq 0) { + Write-Host "::error::No test projects matched {{projectFilter}}" + exit 1 + } + $failedProjects = @() + foreach ($project in $projects) { + Write-Host "Running tests for: $($project.FullName)" + dotnet test $project.FullName --no-build --verbosity normal + if ($LASTEXITCODE -ne 0) { $failedProjects += $project.Name } + } + if ($failedProjects.Count -gt 0) { + Write-Host "::error::Test failures in: $($failedProjects -join ', ')" + exit 1 + } + """; } }