From 749e8f736c1c47f4d13ae649e83af92d0ec35b39 Mon Sep 17 00:00:00 2001 From: Michael Clayson Date: Wed, 27 Aug 2025 17:59:20 +0100 Subject: [PATCH 01/49] temp commit --- .../Common/Mesh/MeshSendCaasSubscribe.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs new file mode 100644 index 0000000000..11dd59bd93 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs @@ -0,0 +1,41 @@ +namespace Common; + +using Microsoft.Extensions.Logging; +using NHS.MESH.Client.Contracts.Services; +using ParquetSharp; + +public class MeshSendCaasSubscribe +{ + private ILogger _logger; + private IMeshOutboxService _meshOutboxService; + public MeshSendCaasSubscribe(ILogger logger, IMeshOutboxService meshOutboxService) + { + _logger = logger; + _meshOutboxService = meshOutboxService; + } + + public string SendSubscriptionRequest(long nhsNumber) + { + + + _meshOutboxService.SendCompressedMessageAsync() + } + + private byte[] CreateParquetFile(long nhsNumber) + { + var columns = new Column[] + { + new Column("NhsNumber"), + }; + using var file = new ParquetFileWriter("float_timeseries.parquet", columns); + using var rowGroup = file.AppendRowGroup(); + + using (var nhsnumber = rowGroup.NextColumn().LogicalWriter()) + { + nhsnumber.WriteBatch() + timestampWriter.WriteBatch(timestamps); + } + + + } +} From 19a54b93d12c527539d9d35ad7a4ea1d1a126437 Mon Sep 17 00:00:00 2001 From: Michael Clayson Date: Thu, 28 Aug 2025 14:59:02 +0100 Subject: [PATCH 02/49] adding mesh client code --- .../Common/Mesh/IMeshSendCaasSubscribe.cs | 6 +++ .../Common/Mesh/MeshSendCaasSubscribe.cs | 49 ++++++++++++++----- .../Mesh/MeshSendCaasSubscribeConfig.cs | 6 +++ .../Common/Mesh/MeshSendCaasSubscribeStub.cs | 12 +++++ 4 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs create mode 100644 application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs create mode 100644 application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs new file mode 100644 index 0000000000..eace189c7b --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs @@ -0,0 +1,6 @@ +namespace Common; + +public interface IMeshSendCaasSubscribe +{ + Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox); +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs index 11dd59bd93..408a68d62e 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs @@ -1,41 +1,68 @@ namespace Common; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NHS.MESH.Client.Contracts.Services; +using NHS.MESH.Client.Models; using ParquetSharp; +using ParquetSharp.IO; -public class MeshSendCaasSubscribe +public class MeshSendCaasSubscribe : IMeshSendCaasSubscribe { private ILogger _logger; private IMeshOutboxService _meshOutboxService; - public MeshSendCaasSubscribe(ILogger logger, IMeshOutboxService meshOutboxService) + private MeshSendCaasSubscribeConfig _config; + public MeshSendCaasSubscribe(ILogger logger, IMeshOutboxService meshOutboxService, IOptions config) { _logger = logger; _meshOutboxService = meshOutboxService; + _config = config.Value; } - public string SendSubscriptionRequest(long nhsNumber) + public async Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox) { + var content = CreateParquetFile(nhsNumber); - _meshOutboxService.SendCompressedMessageAsync() + FileAttachment file = new FileAttachment + { + FileName = "CaaSSubscribe.parquet", + Content = content, + ContentType = "application/octet-stream" + }; + + var result = await _meshOutboxService.SendCompressedMessageAsync(fromMailbox, toMailbox, _config.SendCaasWorkflowId, file); + if (!result.IsSuccessful) + { + _logger.LogError("Could't send mesh message Error Code: {ErrorCode}, Error Description: {ErrorDescription}, ", result.Error.ErrorCode, result.Error.ErrorDescription); + return null; + } + + return result.Response.MessageId; } - private byte[] CreateParquetFile(long nhsNumber) + private static byte[] CreateParquetFile(long nhsNumber) { var columns = new Column[] { new Column("NhsNumber"), }; - using var file = new ParquetFileWriter("float_timeseries.parquet", columns); - using var rowGroup = file.AppendRowGroup(); + long[] nhsNumberList = { nhsNumber }; - using (var nhsnumber = rowGroup.NextColumn().LogicalWriter()) + using (var stream = new MemoryStream()) { - nhsnumber.WriteBatch() - timestampWriter.WriteBatch(timestamps); - } + using var writer = new ManagedOutputStream(stream); + using var file = new ParquetFileWriter(writer, columns); + using var rowGroup = file.AppendRowGroup(); + using (var nhsNumberColumn = rowGroup.NextColumn().LogicalWriter()) + { + nhsNumberColumn.WriteBatch(nhsNumberList); + } + var byteArrayContent = stream.ToArray(); + return byteArrayContent; + } } } diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs new file mode 100644 index 0000000000..f670087086 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs @@ -0,0 +1,6 @@ +namespace Common; + +public class MeshSendCaasSubscribeConfig +{ + public required string SendCaasWorkflowId { get; set; } +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs new file mode 100644 index 0000000000..fb4db3d7e5 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs @@ -0,0 +1,12 @@ +namespace Common; + +using System.Threading.Tasks; + +public class MeshSendCaasSubscribeStub : IMeshSendCaasSubscribe +{ + public async Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox) + { + await Task.CompletedTask; + return $"STUB_{Guid.NewGuid():N}"; + } +} From 471e6243f67f663d521e761a0f9cc9f783e9eb60 Mon Sep 17 00:00:00 2001 From: Michael Clayson Date: Thu, 28 Aug 2025 16:46:49 +0100 Subject: [PATCH 03/49] mesh integration tests --- .../CohortManager/src/Functions/Functions.sln | 14 ++++ .../MeshCaaSSubscribeIntegrationTests.csproj | 26 +++++++ .../SendCaasSubscribeTests.cs | 78 +++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 tests/integration-tests/MeshCaaSSubscribeIntegrationTests/MeshCaaSSubscribeIntegrationTests.csproj create mode 100644 tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs diff --git a/application/CohortManager/src/Functions/Functions.sln b/application/CohortManager/src/Functions/Functions.sln index 2406e88c6e..06948a49c9 100644 --- a/application/CohortManager/src/Functions/Functions.sln +++ b/application/CohortManager/src/Functions/Functions.sln @@ -235,6 +235,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PdsProcessorTests", "..\..\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlobStorageHelperTests", "..\..\..\..\tests\UnitTests\SharedTests\BlobStorageHelperTests\BlobStorageHelperTests.csproj", "{BFA68329-98DD-4039-B009-C6DF23146765}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeshCaaSSubscribeIntegrationTests", "..\..\..\..\tests\integration-tests\MeshCaaSSubscribeIntegrationTests\MeshCaaSSubscribeIntegrationTests.csproj", "{2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1397,6 +1399,18 @@ Global {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x64.Build.0 = Release|Any CPU {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x86.ActiveCfg = Release|Any CPU {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x86.Build.0 = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|x64.ActiveCfg = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|x64.Build.0 = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|x86.ActiveCfg = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Debug|x86.Build.0 = Debug|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|Any CPU.Build.0 = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x64.ActiveCfg = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x64.Build.0 = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x86.ActiveCfg = Release|Any CPU + {2ACD4ADF-2769-4D68-8DB4-5094F384C4BC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/MeshCaaSSubscribeIntegrationTests.csproj b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/MeshCaaSSubscribeIntegrationTests.csproj new file mode 100644 index 0000000000..aff0d515f7 --- /dev/null +++ b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/MeshCaaSSubscribeIntegrationTests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + latest + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs new file mode 100644 index 0000000000..394eb580d1 --- /dev/null +++ b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs @@ -0,0 +1,78 @@ +namespace MeshCaaSSubscribeIntegrationTests; + +using Microsoft.Extensions.DependencyInjection; +using NHS.MESH.Client.Contracts.Services; +using NHS.MESH.Client; +using NHS.MESH.Client.Models; +using NHS.MESH.Client.Contracts.Configurations; +using NHS.MESH.Client.Helpers; +using Common; +using Moq; +using Castle.Core.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Threading.Tasks; + +[TestClass] +public sealed class SendCaasSubscribeTests +{ + private readonly IMeshOutboxService _meshOutboxService; + private readonly IMeshInboxService _meshInboxService; + private readonly IMeshConnectConfiguration _config; + private readonly Mock> _mockLogger = new(); + private readonly Mock> _options = new(); + + private readonly MeshSendCaasSubscribe _sut; + + private const string toMailbox = "X26ABC2"; + private const string fromMailbox = "X26ABC1"; + private const string workflowId = "TEST-WORKFLOW"; + public SendCaasSubscribeTests() + { + var services = new ServiceCollection(); + + services.AddMeshClient(options => + { + options.MeshApiBaseUrl = "http://localhost:8700/messageexchange"; + }) + .AddMailbox(fromMailbox, + new NHS.MESH.Client.Configuration.MailboxConfiguration + { + Password = "password", + SharedKey = "TestKey" + }) + .AddMailbox(toMailbox, + new NHS.MESH.Client.Configuration.MailboxConfiguration + { + Password = "password", + SharedKey = "TestKey" + }) + .Build(); + + _options.Setup(i => i.Value).Returns(new MeshSendCaasSubscribeConfig + { + SendCaasWorkflowId = "Workflow" + }); + + var serviceProvider = services.BuildServiceProvider(); + _meshInboxService = serviceProvider.GetService()!; + _meshOutboxService = serviceProvider.GetService()!; + _config = serviceProvider.GetService()!; + + _sut = new(_mockLogger.Object, _meshOutboxService, _options.Object); + } + + [TestMethod] + public async Task SendSubscriptionRequest_SendsNormalNhsNumber_ReturnsMessageId() + { + // arrange + long nhsNumber = 9995534991; + + // act + var messageId = await _sut.SendSubscriptionRequest(nhsNumber, toMailbox, fromMailbox); + + // assert + Assert.IsNotNull(messageId); + + } +} From fba7ce8d788cbf847a99678960e6a29f57a6fd3b Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 28 Aug 2025 16:51:12 +0100 Subject: [PATCH 04/49] feat: added stubbed version of ManageCaasSubscription function, same signature as ManageNemsSubscription. --- application/CohortManager/.env.example | 5 + application/CohortManager/compose.core.yaml | 23 ++ .../ManageCaasSubscription/Dockerfile | 30 +++ .../HealthCheckFunction.cs | 24 ++ .../ManageCaasSubscription.cs | 197 +++++++++++++++ .../ManageCaasSubscription.csproj | 31 +++ .../ManageCaasSubscriptionConfig.cs | 21 ++ .../ManageCaasSubscription/Program.cs | 22 ++ .../CohortManager/src/Functions/Functions.sln | 59 ++++- .../tf-core/environments/development.tfvars | 16 ++ .../ManageCaasSubscriptionTests.cs | 232 ++++++++++++++++++ .../ManageCaasSubscriptionTests.csproj | 26 ++ 12 files changed, 673 insertions(+), 13 deletions(-) create mode 100644 application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile create mode 100644 application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/HealthCheckFunction.cs create mode 100644 application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs create mode 100644 application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj create mode 100644 application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs create mode 100644 application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs create mode 100644 tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs create mode 100644 tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj diff --git a/application/CohortManager/.env.example b/application/CohortManager/.env.example index bcb23ac58d..6c6274fc7e 100644 --- a/application/CohortManager/.env.example +++ b/application/CohortManager/.env.example @@ -35,3 +35,8 @@ ENDPOINT_BS_SELECT_UPDATE_BLOCK_FLAG="" # "http://localhost:7026/" SERVICENOW_CLIENT_ID= SERVICENOW_CLIENT_SECRET= SERVICENOW_REFRESH_TOKEN= + +# CAAS Manage Subscription (local/dev overrides) +# Optional overrides for CAAS mailbox IDs used by ManageCaasSubscription Subscribe stub +CAAS_SUBSCRIBE_TO_MAILBOX= +CAAS_SUBSCRIBE_FROM_MAILBOX= diff --git a/application/CohortManager/compose.core.yaml b/application/CohortManager/compose.core.yaml index 600ca5bc31..b4569576cb 100644 --- a/application/CohortManager/compose.core.yaml +++ b/application/CohortManager/compose.core.yaml @@ -59,6 +59,8 @@ services: - ExceptionFunctionURL=http://create-exception:7070/api/CreateException - RetrievePdsDemographicURL=http://retrieve-pds-demographic:8082/api/RetrievePDSDemographic - UnsubscribeNemsSubscriptionUrl=http://manage-nems-subscription:9081/api/Unsubscribe + # CAAS manage subscription stub available at manage-caas-subscription when needed + # - UnsubscribeCaasSubscriptionUrl=http://manage-caas-subscription:9084/api/Unsubscribe - DemographicDataServiceURL=http://participant-demographic-data-service:7993/api/ParticipantDemographicDataService - ServiceBusConnectionString_client_internal=Endpoint=sb://service-bus;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true; - ParticipantManagementTopic=participant-management-topic @@ -144,6 +146,8 @@ services: - ExceptionFunctionURL=http://create-exception:7070/api/CreateException - ManageNemsSubscriptionUnsubscribeURL=http://manage-nems-subscription:9081/api/Unsubscribe - ManageNemsSubscriptionSubscribeURL=http://manage-nems-subscription:9081/api/Subscribe + # - ManageCaasSubscriptionUnsubscribeURL=http://manage-caas-subscription:9084/api/Unsubscribe + # - ManageCaasSubscriptionSubscribeURL=http://manage-caas-subscription:9084/api/Subscribe - RetrievePdsDemographicURL=http://etrieve-pds-demographic:8082/api/RetrievePdsDemographic delete-participant: @@ -293,6 +297,25 @@ services: ports: - "9081:9081" + manage-caas-subscription: + container_name: manage-caas-subscription + image: cohort-manager-manage-caas-subscription + networks: [cohman-network] + profiles: [nems] + build: + context: ./src/Functions/ + dockerfile: DemographicServices/ManageCaasSubscription/Dockerfile + environment: + - ASPNETCORE_URLS=http://*:9084 + - FUNCTIONS_WORKER_RUNTIME=dotnet-isolated + - ExceptionFunctionURL=http://create-exception:7070/api/CreateException + - ManageNemsSubscriptionDataServiceURL=http://manage-nems-subscription:9081/api/NemsSubscriptionDataService + - ManageNemsSubscriptionBaseURL=http://manage-nems-subscription:9081 + - CaasToMailbox=${CAAS_SUBSCRIBE_TO_MAILBOX:-CAAS_TO} + - CaasFromMailbox=${CAAS_SUBSCRIBE_FROM_MAILBOX:-CAAS_FROM} + ports: + - "9084:9084" + durable-demographic-function: container_name: durable-demographic-function image: cohort-manager-durable-demographic-function diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile new file mode 100644 index 0000000000..a3aca8f141 --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile @@ -0,0 +1,30 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS base + +COPY ./Shared /Shared +WORKDIR /Shared + +RUN mkdir -p /home/site/wwwroot && \ + dotnet publish ./Common/Common.csproj --output /home/site/wwwroot && \ + dotnet publish ./Model/Model.csproj --output /home/site/wwwroot && \ + dotnet publish ./Data/Data.csproj --output /home/site/wwwroot && \ + dotnet publish ./Utilities/Utilities.csproj --output /home/site/wwwroot && \ + dotnet publish ./DataServices.Client/DataServices.Client.csproj --output /home/site/wwwroot && \ + dotnet publish ./DataServices.Core/DataServices.Core.csproj --output /home/site/wwwroot && \ + dotnet publish ./DataServices.Database/DataServices.Database.csproj --output /home/site/wwwroot + +FROM base AS function + +COPY ./DemographicServices/ManageCaasSubscription /src/dotnet-function-app +WORKDIR /src/dotnet-function-app + +RUN --mount=type=cache,target=/root/.nuget/packages \ + dotnet publish *.csproj --output /home/site/wwwroot + +# To enable ssh & remote debugging on app service change the base image to the one below +# FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0-appservice +FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0 +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true + +COPY --from=function ["/home/site/wwwroot", "/home/site/wwwroot"] + diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/HealthCheckFunction.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/HealthCheckFunction.cs new file mode 100644 index 0000000000..69b48bac03 --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/HealthCheckFunction.cs @@ -0,0 +1,24 @@ +namespace NHS.CohortManager.DemographicServices; + +using System.Threading.Tasks; +using HealthChecks.Extensions; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +public class HealthCheckFunction +{ + private readonly HealthCheckService _healthCheckService; + + public HealthCheckFunction(HealthCheckService healthCheckService) + { + _healthCheckService = healthCheckService; + } + + [Function("health")] + public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) + { + return await HealthCheckServiceExtensions.CreateHealthCheckResponseAsync(req, _healthCheckService); + } +} + diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs new file mode 100644 index 0000000000..dc0f0547cd --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -0,0 +1,197 @@ +namespace NHS.CohortManager.DemographicServices; + +using System.Net; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Common; +using System.Collections.Specialized; +using System.Net.Http; +using System.Text; + +public class ManageCaasSubscription +{ + private readonly ILogger _logger; + private readonly ICreateResponse _createResponse; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IOptions _config; + private readonly IMeshSendCaasSubscribe _meshSendCaasSubscribe; + + public ManageCaasSubscription( + ILogger logger, + ICreateResponse createResponse, + IHttpClientFactory httpClientFactory, + IOptions config, + IMeshSendCaasSubscribe meshSendCaasSubscribe) + { + _logger = logger; + _createResponse = createResponse; + _httpClientFactory = httpClientFactory; + _config = config; + _meshSendCaasSubscribe = meshSendCaasSubscribe; + } + + [Function("Subscribe")] + public async Task Subscribe([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) + { + var nhsNumber = req.Query["nhsNumber"]; + if (!ValidationHelper.ValidateNHSNumber(nhsNumber)) + { + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); + } + + // Forward to MeshSendCaasSubscribeStub (Shared) + long.TryParse(nhsNumber, out var nhsNo); + var toMailbox = _config.Value.CaasToMailbox; + var fromMailbox = _config.Value.CaasFromMailbox; + if (string.IsNullOrWhiteSpace(toMailbox) || string.IsNullOrWhiteSpace(fromMailbox)) + { + _logger.LogError("CAAS mailbox configuration missing. CaasToMailbox or CaasFromMailbox not set."); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "CAAS mailbox configuration missing."); + } + var messageId = await _meshSendCaasSubscribe.SendSubscriptionRequest(nhsNo, toMailbox, fromMailbox); + _logger.LogInformation("CAAS Subscribe forwarded to Mesh stub. NHS: {Nhs}, MessageId: {Msg}", nhsNo, messageId); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, $"Subscription request accepted. MessageId: {messageId}"); + } + + [Function("Unsubscribe")] + public async Task Unsubscribe([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) + { + var nhsNumber = req.Query["nhsNumber"]; + if (!ValidationHelper.ValidateNHSNumber(nhsNumber)) + { + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); + } + + _logger.LogInformation("[CAAS-Stub] Unsubscribe called for NHS: {Nhs}", nhsNumber); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, "Stub: CAAS subscription would be removed."); + } + + [Function("CheckSubscriptionStatus")] + public async Task CheckSubscriptionStatus([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) + { + var baseUrl = _config.Value.ManageNemsSubscriptionBaseURL; + if (!string.IsNullOrWhiteSpace(baseUrl)) + { + try + { + var client = _httpClientFactory.CreateClient(); + var forwardUrl = $"{baseUrl.TrimEnd('/')}/api/CheckSubscriptionStatus{(req.Url?.Query ?? CreateQueryString(req.Query))}"; + + using var forwardRequest = new HttpRequestMessage(HttpMethod.Get, forwardUrl); + var accept = req.Headers.GetValues("Accept").FirstOrDefault(); + if (!string.IsNullOrEmpty(accept)) + { + forwardRequest.Headers.TryAddWithoutValidation("Accept", accept); + } + + var forwardResponse = await client.SendAsync(forwardRequest); + var responseBody = await forwardResponse.Content.ReadAsStringAsync(); + + var resp = _createResponse.CreateHttpResponse(forwardResponse.StatusCode, req, responseBody); + var respContentType = forwardResponse.Content.Headers.ContentType?.ToString(); + if (!string.IsNullOrEmpty(respContentType)) + { + resp.Headers.Add("Content-Type", respContentType); + } + return resp; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error forwarding CheckSubscriptionStatus request"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Error forwarding request to NEMS subscription service."); + } + } + + // Fallback stubbed behaviour if no forward URL configured + var nhsNumber = req.Query["nhsNumber"]; + if (!ValidationHelper.ValidateNHSNumber(nhsNumber)) + { + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); + } + _logger.LogInformation("[CAAS-Stub] CheckSubscriptionStatus called for NHS: {Nhs}", nhsNumber); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, "Stub: CAAS subscription status check."); + } + + [Function("NemsSubscriptionDataService")] + public async Task NemsSubscriptionDataService([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", "put", "delete", Route = "NemsSubscriptionDataService/{*key}")] HttpRequestData req, string? key) + { + var forwardBase = _config.Value.ManageNemsSubscriptionDataServiceURL; + if (string.IsNullOrWhiteSpace(forwardBase)) + { + _logger.LogInformation("[CAAS-Stub] Forward URL not configured; returning stub response. Method: {Method}, Key: {Key}", req.Method, key); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, "Stub: CAAS data service placeholder."); + } + + try + { + var client = _httpClientFactory.CreateClient(); + + // Build forward URL preserving key and query + var pathPart = string.IsNullOrEmpty(key) ? string.Empty : $"/{key}"; + var query = req.Url?.Query ?? CreateQueryString(req.Query); // includes leading '?', or empty + var forwardUrl = $"{forwardBase.TrimEnd('/')}{pathPart}{query}"; + + var method = new HttpMethod(req.Method); + + HttpContent? content = null; + // Only attach body for methods that typically have one + if (string.Equals(req.Method, HttpMethod.Post.Method, StringComparison.OrdinalIgnoreCase) || + string.Equals(req.Method, HttpMethod.Put.Method, StringComparison.OrdinalIgnoreCase) || + string.Equals(req.Method, "PATCH", StringComparison.OrdinalIgnoreCase)) + { + if (req.Body != null) + { + req.Body.Position = 0; + using var reader = new StreamReader(req.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + var contentType = req.Headers.GetValues("Content-Type").FirstOrDefault() ?? "application/json"; + content = new StringContent(body, Encoding.UTF8, contentType); + } + } + + using var forwardRequest = new HttpRequestMessage(method, forwardUrl) + { + Content = content + }; + + // Basic header pass-through (Accept) + var accept = req.Headers.GetValues("Accept").FirstOrDefault(); + if (!string.IsNullOrEmpty(accept)) + { + forwardRequest.Headers.TryAddWithoutValidation("Accept", accept); + } + + var forwardResponse = await client.SendAsync(forwardRequest); + var responseBody = await forwardResponse.Content.ReadAsStringAsync(); + + var resp = _createResponse.CreateHttpResponse(forwardResponse.StatusCode, req, responseBody); + var respContentType = forwardResponse.Content.Headers.ContentType?.ToString(); + if (!string.IsNullOrEmpty(respContentType)) + { + resp.Headers.Add("Content-Type", respContentType); + } + return resp; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error forwarding NemsSubscriptionDataService request"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Error forwarding request to NEMS subscription service."); + } + } + + private static string CreateQueryString(NameValueCollection? query) + { + if (query == null || query.Count == 0) return string.Empty; + var parts = new List(); + foreach (var key in query.AllKeys) + { + if (key == null) continue; + var value = query[key] ?? string.Empty; + parts.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); + } + return parts.Count > 0 ? $"?{string.Join("&", parts)}" : string.Empty; + } +} diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj new file mode 100644 index 0000000000..cbed875eab --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj @@ -0,0 +1,31 @@ + + + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10} + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + + diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs new file mode 100644 index 0000000000..03bf694158 --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs @@ -0,0 +1,21 @@ +namespace NHS.CohortManager.DemographicServices; + +// Minimal config object for ManageCaasSubscription. +// Intentionally no [Required] attributes so binding does not fail if unset. +public class ManageCaasSubscriptionConfig +{ + // Optional URL for pass-through to the existing NEMS data service + // Example: http://manage-nems-subscription:9081/api/NemsSubscriptionDataService + public string? ManageNemsSubscriptionDataServiceURL { get; set; } + + // Optional base URL to forward selected endpoints (e.g., CheckSubscriptionStatus) + // Example: http://manage-nems-subscription:9081 + public string? ManageNemsSubscriptionBaseURL { get; set; } + + // Optional CAAS mailboxes for the subscribe stub + public string? CaasToMailbox { get; set; } + public string? CaasFromMailbox { get; set; } + + // Controls whether shared implementations should use stubbed behavior + public bool IsStubbed { get; set; } = true; +} diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs new file mode 100644 index 0000000000..12a6f94148 --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using HealthChecks.Extensions; +using Common; +using NHS.CohortManager.DemographicServices; + +var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .AddConfiguration(out ManageCaasSubscriptionConfig config) + .ConfigureServices(services => + { + services.AddSingleton(); + services.AddBasicHealthCheck("ManageCaasSubscription"); + services.AddSingleton(); + }) + .AddHttpClient() + .AddTelemetry() + .AddExceptionHandler() + .Build(); + +await host.RunAsync(); diff --git a/application/CohortManager/src/Functions/Functions.sln b/application/CohortManager/src/Functions/Functions.sln index 2406e88c6e..c4cc60d17b 100644 --- a/application/CohortManager/src/Functions/Functions.sln +++ b/application/CohortManager/src/Functions/Functions.sln @@ -187,8 +187,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UpdateBlockedFlagTests", ". EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManageNemsSubscription", "DemographicServices\ManageNemsSubscription\ManageNemsSubscription.csproj", "{800E7765-EC47-4343-A52C-16DF435B24D3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManageCaasSubscription", "DemographicServices\ManageCaasSubscription\ManageCaasSubscription.csproj", "{B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManageNemsSubscriptionTests", "..\..\..\..\tests\UnitTests\DemographicServicesTests\ManageNemsSubscriptionTests\ManageNemsSubscriptionTests.csproj", "{A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManageCaasSubscriptionTests", "..\..\..\..\tests\UnitTests\DemographicServicesTests\ManageCaasSubscriptionTests\ManageCaasSubscriptionTests.csproj", "{C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReferenceDataService", "screeningDataServices\ReferenceDataService\ReferenceDataService.csproj", "{8DFEDCFA-5A62-461E-94D3-3DA9609F26BB}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProcessNemsUpdate", "NemsSubscriptionService\ProcessNemsUpdate\ProcessNemsUpdate.csproj", "{1538902B-C11F-492B-86A6-86A0DB72C761}" @@ -235,6 +239,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PdsProcessorTests", "..\..\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlobStorageHelperTests", "..\..\..\..\tests\UnitTests\SharedTests\BlobStorageHelperTests\BlobStorageHelperTests.csproj", "{BFA68329-98DD-4039-B009-C6DF23146765}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DemographicServicesTests", "DemographicServicesTests", "{3F84AC12-B761-25DE-181D-4BFB8336C214}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1145,6 +1151,18 @@ Global {800E7765-EC47-4343-A52C-16DF435B24D3}.Release|x64.Build.0 = Release|Any CPU {800E7765-EC47-4343-A52C-16DF435B24D3}.Release|x86.ActiveCfg = Release|Any CPU {800E7765-EC47-4343-A52C-16DF435B24D3}.Release|x86.Build.0 = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|x64.ActiveCfg = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|x64.Build.0 = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|x86.ActiveCfg = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Debug|x86.Build.0 = Debug|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|Any CPU.Build.0 = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|x64.ActiveCfg = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|x64.Build.0 = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|x86.ActiveCfg = Release|Any CPU + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10}.Release|x86.Build.0 = Release|Any CPU {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Debug|Any CPU.Build.0 = Debug|Any CPU {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1157,6 +1175,18 @@ Global {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Release|x64.Build.0 = Release|Any CPU {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Release|x86.ActiveCfg = Release|Any CPU {A2E3F772-BC8C-4F48-B31E-11AFA5E15CC8}.Release|x86.Build.0 = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|x64.Build.0 = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Debug|x86.Build.0 = Debug|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|Any CPU.Build.0 = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|x64.ActiveCfg = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|x64.Build.0 = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|x86.ActiveCfg = Release|Any CPU + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E}.Release|x86.Build.0 = Release|Any CPU {8DFEDCFA-5A62-461E-94D3-3DA9609F26BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8DFEDCFA-5A62-461E-94D3-3DA9609F26BB}.Debug|Any CPU.Build.0 = Debug|Any CPU {8DFEDCFA-5A62-461E-94D3-3DA9609F26BB}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1193,6 +1223,18 @@ Global {0F8F78E8-9DC7-4BF7-8BA8-E56A79615434}.Release|x64.Build.0 = Release|Any CPU {0F8F78E8-9DC7-4BF7-8BA8-E56A79615434}.Release|x86.ActiveCfg = Release|Any CPU {0F8F78E8-9DC7-4BF7-8BA8-E56A79615434}.Release|x86.Build.0 = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x64.Build.0 = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x86.ActiveCfg = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x86.Build.0 = Debug|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|Any CPU.Build.0 = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x64.ActiveCfg = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x64.Build.0 = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x86.ActiveCfg = Release|Any CPU + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x86.Build.0 = Release|Any CPU {33C49482-E216-4170-A2B9-00C816206566}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {33C49482-E216-4170-A2B9-00C816206566}.Debug|Any CPU.Build.0 = Debug|Any CPU {33C49482-E216-4170-A2B9-00C816206566}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1385,18 +1427,6 @@ Global {BFA68329-98DD-4039-B009-C6DF23146765}.Release|x64.Build.0 = Release|Any CPU {BFA68329-98DD-4039-B009-C6DF23146765}.Release|x86.ActiveCfg = Release|Any CPU {BFA68329-98DD-4039-B009-C6DF23146765}.Release|x86.Build.0 = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x64.ActiveCfg = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x64.Build.0 = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x86.ActiveCfg = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Debug|x86.Build.0 = Debug|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|Any CPU.Build.0 = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x64.ActiveCfg = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x64.Build.0 = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x86.ActiveCfg = Release|Any CPU - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1448,17 +1478,20 @@ Global {AF3A5F34-77F2-7915-5806-A9586C50EB46} = {2F646680-91A4-F107-74F4-9BF3126A0F16} {A4EBDA0F-060E-4454-AD01-D56ECC2D1BBE} = {AF3A5F34-77F2-7915-5806-A9586C50EB46} {800E7765-EC47-4343-A52C-16DF435B24D3} = {0DDB5B2A-AD97-BFD6-A081-43D49FE0B20F} + {B0B2C4F4-AB0E-4C4D-9E1B-1F2C4E7F8A10} = {0DDB5B2A-AD97-BFD6-A081-43D49FE0B20F} + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E} = {2F646680-91A4-F107-74F4-9BF3126A0F16} {8DFEDCFA-5A62-461E-94D3-3DA9609F26BB} = {006A9B93-41E9-9D6B-03D8-80B6AB48B9E5} {1538902B-C11F-492B-86A6-86A0DB72C761} = {81415B60-C1D4-887B-0030-6F6B04DE9059} + {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B} = {CA13ADEB-51D6-44BD-8721-C858D693B481} {6F35BE1C-121E-5D91-98E7-83CAF153EF2E} = {19500E0D-AAAB-6F02-E24F-82619ACA2290} {87F24206-BDE4-471F-B031-791308443CEE} = {72FBAE8E-5F64-9545-D6D4-8F59D0E91151} {DF48630C-8B25-433F-B5CA-C838CB34D0A6} = {006A9B93-41E9-9D6B-03D8-80B6AB48B9E5} {5641FB51-B7C1-445A-A210-EB985E05CC9E} = {CA13ADEB-51D6-44BD-8721-C858D693B481} - {7C5D0C23-FB1B-4D95-A3A0-AC217B1CFC1B} = {CA13ADEB-51D6-44BD-8721-C858D693B481} {4A786537-26D6-4964-BB53-C9B03A0CE5D6} = {19500E0D-AAAB-6F02-E24F-82619ACA2290} {52C72C2E-9A76-4ECF-A210-C3F7C584C193} = {19500E0D-AAAB-6F02-E24F-82619ACA2290} {4BD680A2-1ACB-7D6B-B2FD-8EBE9AEB5050} = {E8E33C5F-F9FB-3ACA-2B58-298ED48517C1} {59CBDBE5-29BE-F38C-80E6-40843F2F8AF6} = {E8E33C5F-F9FB-3ACA-2B58-298ED48517C1} {392B3D99-C5C5-DB9F-4DCA-F389E679C7C0} = {5555D2A1-8C8F-5B64-9F84-08EFE0FC7CD8} + {3F84AC12-B761-25DE-181D-4BFB8336C214} = {2F646680-91A4-F107-74F4-9BF3126A0F16} EndGlobalSection EndGlobal diff --git a/infrastructure/tf-core/environments/development.tfvars b/infrastructure/tf-core/environments/development.tfvars index 2d311aeed3..bd0e15bd18 100644 --- a/infrastructure/tf-core/environments/development.tfvars +++ b/infrastructure/tf-core/environments/development.tfvars @@ -1051,6 +1051,22 @@ function_apps = { } } + ManageCaasSubscription = { + name_suffix = "manage-caas-subscription" + function_endpoint_name = "ManageCaasSubscription" + app_service_plan_key = "NonScaling" + app_urls = [ + { + env_var_name = "ExceptionFunctionURL" + function_app_key = "CreateException" + } + ] + env_vars_static = { + # Minimal stubbed config; expand later if needed + IsStubbed = "true" + } + } + ReferenceDataService = { name_suffix = "reference-data-service" function_endpoint_name = "ReferenceDataService" diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs new file mode 100644 index 0000000000..60b72301c7 --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -0,0 +1,232 @@ +namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; + +using System.Net; +using System.Collections.Specialized; +using System.Threading.Tasks; +using Common; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NHS.CohortManager.DemographicServices; +using NHS.CohortManager.Tests.TestUtils; +using System.Net.Http; +using System.Net; + +[TestClass] +public class ManageCaasSubscriptionTests +{ + private readonly CreateResponse _createResponse = new(); + private readonly Mock> _logger = new(); + private readonly SetupRequest _setupRequest = new(); + private readonly ManageCaasSubscription _sut; + private readonly Mock _httpClientFactory = new(); + private readonly Mock> _config = new(); + private readonly Mock _mesh = new(); + + public ManageCaasSubscriptionTests() + { + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig + { + ManageNemsSubscriptionDataServiceURL = null, // keep stub mode during unit tests + CaasToMailbox = "TEST_TO", + CaasFromMailbox = "TEST_FROM" + }); + + _mesh + .Setup(m => m.SendSubscriptionRequest(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync("STUB_MSG_ID"); + + _sut = new ManageCaasSubscription( + _logger.Object, + _createResponse, + _httpClientFactory.Object, + _config.Object, + _mesh.Object + ); + } + + [TestMethod] + public async Task Subscribe_Valid_ReturnsOk() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + } + + [TestMethod] + public async Task Subscribe_Invalid_ReturnsBadRequest() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "abc" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.BadRequest, res.StatusCode); + } + + [TestMethod] + public async Task Subscribe_MissingMailboxes_ReturnsInternalServerError() + { + // Arrange: missing config values + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig + { + ManageNemsSubscriptionDataServiceURL = null, + CaasToMailbox = null, + CaasFromMailbox = null + }); + + var sutMissing = new ManageCaasSubscription( + _logger.Object, + _createResponse, + _httpClientFactory.Object, + _config.Object, + _mesh.Object + ); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + + // Act + var res = await sutMissing.Subscribe(req.Object); + + // Assert + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + } + + [TestMethod] + public async Task Unsubscribe_Valid_ReturnsOk() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Unsubscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + } + + [TestMethod] + public async Task CheckSubscriptionStatus_Valid_ReturnsOk() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Get); + var res = await _sut.CheckSubscriptionStatus(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + } + + [TestMethod] + public async Task DataService_ReturnsOk() + { + var req = _setupRequest.Setup(null, new NameValueCollection(), HttpMethod.Get); + var res = await _sut.NemsSubscriptionDataService(req.Object, "key"); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + } + + [TestMethod] + public async Task CheckSubscriptionStatus_Forwarded_PropagatesResponse() + { + // Arrange + var handler = new TestHandler(async (message) => + { + Assert.AreEqual(HttpMethod.Get, message.Method); + StringAssert.Contains(message.RequestUri.ToString(), "/api/CheckSubscriptionStatus?nhsNumber=9000000009"); + return new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent("FORWARDED", System.Text.Encoding.UTF8, "application/json") + }; + }); + var httpClient = new HttpClient(handler); + _httpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig + { + ManageNemsSubscriptionBaseURL = "http://downstream", + CaasToMailbox = "TEST_TO", + CaasFromMailbox = "TEST_FROM" + }); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Get); + + // Act + var res = await _sut.CheckSubscriptionStatus(req.Object); + var body = await AssertionHelper.ReadResponseBodyAsync(res); + + // Assert + Assert.AreEqual(HttpStatusCode.Accepted, res.StatusCode); + Assert.AreEqual("FORWARDED", body); + } + + [TestMethod] + public async Task NemsSubscriptionDataService_Forwarded_GetWithKeyAndQuery_Propagates() + { + // Arrange + var handler = new TestHandler(async (message) => + { + Assert.AreEqual(HttpMethod.Get, message.Method); + StringAssert.Contains(message.RequestUri.ToString(), "/api/NemsSubscriptionDataService/my-key?foo=bar"); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("FWD-OK") + }; + }); + var httpClient = new HttpClient(handler); + _httpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig + { + ManageNemsSubscriptionDataServiceURL = "http://downstream/api/NemsSubscriptionDataService", + CaasToMailbox = "TEST_TO", + CaasFromMailbox = "TEST_FROM" + }); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "foo", "bar" } }, HttpMethod.Get); + + // Act + var res = await _sut.NemsSubscriptionDataService(req.Object, "my-key"); + var body = await AssertionHelper.ReadResponseBodyAsync(res); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + Assert.AreEqual("FWD-OK", body); + } + + [TestMethod] + public async Task NemsSubscriptionDataService_Forwarded_PostBody_Propagates() + { + // Arrange + var handler = new TestHandler(async (message) => + { + Assert.AreEqual(HttpMethod.Post, message.Method); + var content = await message.Content.ReadAsStringAsync(); + Assert.AreEqual("{\"foo\":\"bar\"}", content); + return new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent("CREATED") + }; + }); + var httpClient = new HttpClient(handler); + _httpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig + { + ManageNemsSubscriptionDataServiceURL = "http://downstream/api/NemsSubscriptionDataService", + CaasToMailbox = "TEST_TO", + CaasFromMailbox = "TEST_FROM" + }); + + var req = _setupRequest.Setup("{\"foo\":\"bar\"}", new NameValueCollection(), HttpMethod.Post); + + // Act + var res = await _sut.NemsSubscriptionDataService(req.Object, "abc"); + var body = await AssertionHelper.ReadResponseBodyAsync(res); + + // Assert + Assert.AreEqual(HttpStatusCode.Created, res.StatusCode); + Assert.AreEqual("CREATED", body); + } + + private sealed class TestHandler : HttpMessageHandler + { + private readonly Func> _handler; + public TestHandler(Func> handler) + { + _handler = handler; + } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _handler(request); + } + } +} diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj new file mode 100644 index 0000000000..8743dc1cc0 --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj @@ -0,0 +1,26 @@ + + + {C1C2D3E4-5678-49AB-9CDE-0F1A2B3C4D5E} + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + From 85aa9806af0b8bc6a208cba1480cc0cfbc49e0ea Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 28 Aug 2025 16:56:07 +0100 Subject: [PATCH 05/49] fix: corrected placeholder config in compose.core.yml --- application/CohortManager/compose.core.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/application/CohortManager/compose.core.yaml b/application/CohortManager/compose.core.yaml index b4569576cb..f262b694b0 100644 --- a/application/CohortManager/compose.core.yaml +++ b/application/CohortManager/compose.core.yaml @@ -126,6 +126,7 @@ services: - SendServiceNowMessageURL=http://servicenow-message-handler:9092/api/servicenow/send - ParticipantManagementURL=http://participant-management-data-service:7994/api/ParticipantManagementDataService - ManageNemsSubscriptionSubscribeURL=http://manage-nems-subscription:9081/api/Subscribe + # - ManageNemsSubscriptionSubscribeURL=http://manage-caas-subscription:9084/api/Subscribe - CohortDistributionTopic=cohort-distribution-topic update-blocked-flag: @@ -146,8 +147,8 @@ services: - ExceptionFunctionURL=http://create-exception:7070/api/CreateException - ManageNemsSubscriptionUnsubscribeURL=http://manage-nems-subscription:9081/api/Unsubscribe - ManageNemsSubscriptionSubscribeURL=http://manage-nems-subscription:9081/api/Subscribe - # - ManageCaasSubscriptionUnsubscribeURL=http://manage-caas-subscription:9084/api/Unsubscribe - # - ManageCaasSubscriptionSubscribeURL=http://manage-caas-subscription:9084/api/Subscribe + # - ManageNemsSubscriptionUnsubscribeURL=http://manage-caas-subscription:9084/api/Unsubscribe + # - ManageNemsSubscriptionSubscribeURL=http://manage-caas-subscription:9084/api/Subscribe - RetrievePdsDemographicURL=http://etrieve-pds-demographic:8082/api/RetrievePdsDemographic delete-participant: From f757433231650f60fe3f0843b800f186dc07c3cb Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 28 Aug 2025 17:02:05 +0100 Subject: [PATCH 06/49] fix: unit test project references were missing --- tests/UnitTests/ConsolidatedTests.csproj | 1 + .../ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/UnitTests/ConsolidatedTests.csproj b/tests/UnitTests/ConsolidatedTests.csproj index a3fc160f3b..a8d104a096 100644 --- a/tests/UnitTests/ConsolidatedTests.csproj +++ b/tests/UnitTests/ConsolidatedTests.csproj @@ -93,6 +93,7 @@ + diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index 60b72301c7..eef49c336d 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -11,7 +11,6 @@ namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; using NHS.CohortManager.DemographicServices; using NHS.CohortManager.Tests.TestUtils; using System.Net.Http; -using System.Net; [TestClass] public class ManageCaasSubscriptionTests From 60fc83fe1995b3ae57a3180f99a4eab16c6fc03c Mon Sep 17 00:00:00 2001 From: Michael Clayson Date: Thu, 28 Aug 2025 17:17:14 +0100 Subject: [PATCH 07/49] testing parquet file --- .../SendCaasSubscribeTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs index 394eb580d1..b8d661bc26 100644 --- a/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs +++ b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Threading.Tasks; +using NHS.MESH.Client.Helpers.ContentHelpers; [TestClass] public sealed class SendCaasSubscribeTests @@ -74,5 +75,19 @@ public async Task SendSubscriptionRequest_SendsNormalNhsNumber_ReturnsMessageId( // assert Assert.IsNotNull(messageId); + // act - validate message recieved + var getMessagesResult = await _meshInboxService.GetMessagesAsync(toMailbox); + + Assert.IsTrue(getMessagesResult.Response.Messages.Contains(messageId)); + + var message = await _meshInboxService.GetMessageByIdAsync(toMailbox, messageId); + + var fileContent = GZIPHelpers.DeCompressBuffer(message.Response.FileAttachment.Content); + + + + File.WriteAllBytes("test.parquet",fileContent); + + } } From a3b82924cb352180910d79019505703eb452b6cc Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 28 Aug 2025 17:18:16 +0100 Subject: [PATCH 08/49] fix: cleared up forwarder code - reimplemented NemsSubscriptionDataService and CheckSubscriptionStatus into ManageCaasSubscription --- .../ManageCaasSubscription.cs | 138 ++++-------------- .../ManageCaasSubscription.csproj | 3 + .../ManageCaasSubscription/Program.cs | 3 + .../ManageCaasSubscriptionTests.cs | 133 +++++------------ 4 files changed, 76 insertions(+), 201 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs index dc0f0547cd..3bf89b012a 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -8,29 +8,33 @@ namespace NHS.CohortManager.DemographicServices; using Microsoft.Extensions.Options; using Common; using System.Collections.Specialized; -using System.Net.Http; using System.Text; +using DataServices.Core; +using Model; public class ManageCaasSubscription { private readonly ILogger _logger; private readonly ICreateResponse _createResponse; - private readonly IHttpClientFactory _httpClientFactory; private readonly IOptions _config; private readonly IMeshSendCaasSubscribe _meshSendCaasSubscribe; + private readonly IRequestHandler _requestHandler; + private readonly IDataServiceAccessor _nemsSubscriptionAccessor; public ManageCaasSubscription( ILogger logger, ICreateResponse createResponse, - IHttpClientFactory httpClientFactory, IOptions config, - IMeshSendCaasSubscribe meshSendCaasSubscribe) + IMeshSendCaasSubscribe meshSendCaasSubscribe, + IRequestHandler requestHandler, + IDataServiceAccessor nemsSubscriptionAccessor) { _logger = logger; _createResponse = createResponse; - _httpClientFactory = httpClientFactory; _config = config; _meshSendCaasSubscribe = meshSendCaasSubscribe; + _requestHandler = requestHandler; + _nemsSubscriptionAccessor = nemsSubscriptionAccessor; } [Function("Subscribe")] @@ -72,126 +76,48 @@ public async Task Unsubscribe([HttpTrigger(AuthorizationLevel. [Function("CheckSubscriptionStatus")] public async Task CheckSubscriptionStatus([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) { - var baseUrl = _config.Value.ManageNemsSubscriptionBaseURL; - if (!string.IsNullOrWhiteSpace(baseUrl)) - { - try - { - var client = _httpClientFactory.CreateClient(); - var forwardUrl = $"{baseUrl.TrimEnd('/')}/api/CheckSubscriptionStatus{(req.Url?.Query ?? CreateQueryString(req.Query))}"; - - using var forwardRequest = new HttpRequestMessage(HttpMethod.Get, forwardUrl); - var accept = req.Headers.GetValues("Accept").FirstOrDefault(); - if (!string.IsNullOrEmpty(accept)) - { - forwardRequest.Headers.TryAddWithoutValidation("Accept", accept); - } - - var forwardResponse = await client.SendAsync(forwardRequest); - var responseBody = await forwardResponse.Content.ReadAsStringAsync(); - - var resp = _createResponse.CreateHttpResponse(forwardResponse.StatusCode, req, responseBody); - var respContentType = forwardResponse.Content.Headers.ContentType?.ToString(); - if (!string.IsNullOrEmpty(respContentType)) - { - resp.Headers.Add("Content-Type", respContentType); - } - return resp; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error forwarding CheckSubscriptionStatus request"); - return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Error forwarding request to NEMS subscription service."); - } - } - - // Fallback stubbed behaviour if no forward URL configured - var nhsNumber = req.Query["nhsNumber"]; - if (!ValidationHelper.ValidateNHSNumber(nhsNumber)) - { - return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); - } - _logger.LogInformation("[CAAS-Stub] CheckSubscriptionStatus called for NHS: {Nhs}", nhsNumber); - return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, "Stub: CAAS subscription status check."); - } - - [Function("NemsSubscriptionDataService")] - public async Task NemsSubscriptionDataService([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", "put", "delete", Route = "NemsSubscriptionDataService/{*key}")] HttpRequestData req, string? key) - { - var forwardBase = _config.Value.ManageNemsSubscriptionDataServiceURL; - if (string.IsNullOrWhiteSpace(forwardBase)) - { - _logger.LogInformation("[CAAS-Stub] Forward URL not configured; returning stub response. Method: {Method}, Key: {Key}", req.Method, key); - return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, "Stub: CAAS data service placeholder."); - } - try { - var client = _httpClientFactory.CreateClient(); - - // Build forward URL preserving key and query - var pathPart = string.IsNullOrEmpty(key) ? string.Empty : $"/{key}"; - var query = req.Url?.Query ?? CreateQueryString(req.Query); // includes leading '?', or empty - var forwardUrl = $"{forwardBase.TrimEnd('/')}{pathPart}{query}"; + _logger.LogInformation("Received check subscription request"); - var method = new HttpMethod(req.Method); + string? nhsNumber = req.Query["nhsNumber"]; - HttpContent? content = null; - // Only attach body for methods that typically have one - if (string.Equals(req.Method, HttpMethod.Post.Method, StringComparison.OrdinalIgnoreCase) || - string.Equals(req.Method, HttpMethod.Put.Method, StringComparison.OrdinalIgnoreCase) || - string.Equals(req.Method, "PATCH", StringComparison.OrdinalIgnoreCase)) + if (!ValidationHelper.ValidateNHSNumber(nhsNumber)) { - if (req.Body != null) - { - req.Body.Position = 0; - using var reader = new StreamReader(req.Body, Encoding.UTF8, leaveOpen: true); - var body = await reader.ReadToEndAsync(); - var contentType = req.Headers.GetValues("Content-Type").FirstOrDefault() ?? "application/json"; - content = new StringContent(body, Encoding.UTF8, contentType); - } + _logger.LogError("NHS number is required and must be valid format"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); } - using var forwardRequest = new HttpRequestMessage(method, forwardUrl) - { - Content = content - }; + var record = await _nemsSubscriptionAccessor.GetSingle(i => i.NhsNumber == long.Parse(nhsNumber!)); + string? subscriptionId = record?.SubscriptionId; - // Basic header pass-through (Accept) - var accept = req.Headers.GetValues("Accept").FirstOrDefault(); - if (!string.IsNullOrEmpty(accept)) + if (string.IsNullOrEmpty(subscriptionId)) { - forwardRequest.Headers.TryAddWithoutValidation("Accept", accept); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.NotFound, req, "No subscription found for this NHS number."); } - var forwardResponse = await client.SendAsync(forwardRequest); - var responseBody = await forwardResponse.Content.ReadAsStringAsync(); - - var resp = _createResponse.CreateHttpResponse(forwardResponse.StatusCode, req, responseBody); - var respContentType = forwardResponse.Content.Headers.ContentType?.ToString(); - if (!string.IsNullOrEmpty(respContentType)) - { - resp.Headers.Add("Content-Type", respContentType); - } - return resp; + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, $"Active subscription found. Subscription ID: {subscriptionId}"); } catch (Exception ex) { - _logger.LogError(ex, "Error forwarding NemsSubscriptionDataService request"); - return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Error forwarding request to NEMS subscription service."); + _logger.LogError(ex, "Error checking subscription status"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "An error occurred while checking subscription status."); } } - private static string CreateQueryString(NameValueCollection? query) + [Function("NemsSubscriptionDataService")] + public async Task NemsSubscriptionDataService([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", "put", "delete", Route = "NemsSubscriptionDataService/{*key}")] HttpRequestData req, string? key) { - if (query == null || query.Count == 0) return string.Empty; - var parts = new List(); - foreach (var key in query.AllKeys) + try + { + _logger.LogInformation("DataService Request Received Method: {Method}, DataObject {DataType} ", req.Method, typeof(NemsSubscription)); + var result = await _requestHandler.HandleRequest(req, key); + return result; + } + catch (Exception ex) { - if (key == null) continue; - var value = query[key] ?? string.Empty; - parts.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}"); + _logger.LogError(ex, "An error has occurred in data service"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "An error occurred while processing the data service request."); } - return parts.Count > 0 ? $"?{string.Join("&", parts)}" : string.Empty; } } diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj index cbed875eab..23425ce067 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj @@ -27,5 +27,8 @@ + + + diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs index 12a6f94148..d79449791f 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs @@ -4,6 +4,8 @@ using HealthChecks.Extensions; using Common; using NHS.CohortManager.DemographicServices; +using DataServices.Database; +using DataServices.Core; var host = new HostBuilder() .ConfigureFunctionsWebApplication() @@ -14,6 +16,7 @@ services.AddBasicHealthCheck("ManageCaasSubscription"); services.AddSingleton(); }) + .AddDataServicesHandler() .AddHttpClient() .AddTelemetry() .AddExceptionHandler() diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index eef49c336d..e319c981b7 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -11,6 +11,8 @@ namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; using NHS.CohortManager.DemographicServices; using NHS.CohortManager.Tests.TestUtils; using System.Net.Http; +using DataServices.Core; +using Model; [TestClass] public class ManageCaasSubscriptionTests @@ -19,9 +21,10 @@ public class ManageCaasSubscriptionTests private readonly Mock> _logger = new(); private readonly SetupRequest _setupRequest = new(); private readonly ManageCaasSubscription _sut; - private readonly Mock _httpClientFactory = new(); private readonly Mock> _config = new(); private readonly Mock _mesh = new(); + private readonly Mock> _requestHandler = new(); + private readonly Mock> _nemsAccessor = new(); public ManageCaasSubscriptionTests() { @@ -36,12 +39,23 @@ public ManageCaasSubscriptionTests() .Setup(m => m.SendSubscriptionRequest(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync("STUB_MSG_ID"); + // Default: pretend a subscription exists so generic tests expect OK + _nemsAccessor + .Setup(a => a.GetSingle(It.IsAny>>() )) + .ReturnsAsync(new NemsSubscription { NhsNumber = 9000000009, SubscriptionId = "SUB123" }); + + // Default: request handler returns OK for data-service calls + _requestHandler + .Setup(r => r.HandleRequest(It.IsAny(), It.IsAny())) + .ReturnsAsync((HttpRequestData r, string k) => _createResponse.CreateHttpResponse(HttpStatusCode.OK, r, "OK")); + _sut = new ManageCaasSubscription( _logger.Object, _createResponse, - _httpClientFactory.Object, _config.Object, - _mesh.Object + _mesh.Object, + _requestHandler.Object, + _nemsAccessor.Object ); } @@ -75,9 +89,10 @@ public async Task Subscribe_MissingMailboxes_ReturnsInternalServerError() var sutMissing = new ManageCaasSubscription( _logger.Object, _createResponse, - _httpClientFactory.Object, _config.Object, - _mesh.Object + _mesh.Object, + _requestHandler.Object, + _nemsAccessor.Object ); var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); @@ -114,118 +129,46 @@ public async Task DataService_ReturnsOk() } [TestMethod] - public async Task CheckSubscriptionStatus_Forwarded_PropagatesResponse() + public async Task CheckSubscriptionStatus_Found_ReturnsOk() { - // Arrange - var handler = new TestHandler(async (message) => - { - Assert.AreEqual(HttpMethod.Get, message.Method); - StringAssert.Contains(message.RequestUri.ToString(), "/api/CheckSubscriptionStatus?nhsNumber=9000000009"); - return new HttpResponseMessage(HttpStatusCode.Accepted) - { - Content = new StringContent("FORWARDED", System.Text.Encoding.UTF8, "application/json") - }; - }); - var httpClient = new HttpClient(handler); - _httpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); - - _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig - { - ManageNemsSubscriptionBaseURL = "http://downstream", - CaasToMailbox = "TEST_TO", - CaasFromMailbox = "TEST_FROM" - }); + _nemsAccessor + .Setup(a => a.GetSingle(It.IsAny>>() )) + .ReturnsAsync(new NemsSubscription { NhsNumber = 9000000009, SubscriptionId = "SUB123" }); var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Get); // Act var res = await _sut.CheckSubscriptionStatus(req.Object); - var body = await AssertionHelper.ReadResponseBodyAsync(res); // Assert - Assert.AreEqual(HttpStatusCode.Accepted, res.StatusCode); - Assert.AreEqual("FORWARDED", body); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); } [TestMethod] - public async Task NemsSubscriptionDataService_Forwarded_GetWithKeyAndQuery_Propagates() + public async Task NemsSubscriptionDataService_ValidRequest_DelegatesToHandler() { - // Arrange - var handler = new TestHandler(async (message) => - { - Assert.AreEqual(HttpMethod.Get, message.Method); - StringAssert.Contains(message.RequestUri.ToString(), "/api/NemsSubscriptionDataService/my-key?foo=bar"); - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("FWD-OK") - }; - }); - var httpClient = new HttpClient(handler); - _httpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); - - _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig - { - ManageNemsSubscriptionDataServiceURL = "http://downstream/api/NemsSubscriptionDataService", - CaasToMailbox = "TEST_TO", - CaasFromMailbox = "TEST_FROM" - }); - - var req = _setupRequest.Setup(null, new NameValueCollection { { "foo", "bar" } }, HttpMethod.Get); + var req = _setupRequest.Setup(null, new NameValueCollection(), HttpMethod.Get); + var expected = _createResponse.CreateHttpResponse(HttpStatusCode.OK, req.Object, "OK"); + _requestHandler + .Setup(r => r.HandleRequest(It.IsAny(), It.IsAny())) + .ReturnsAsync(expected); // Act var res = await _sut.NemsSubscriptionDataService(req.Object, "my-key"); - var body = await AssertionHelper.ReadResponseBodyAsync(res); // Assert Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); - Assert.AreEqual("FWD-OK", body); } [TestMethod] - public async Task NemsSubscriptionDataService_Forwarded_PostBody_Propagates() + public async Task CheckSubscriptionStatus_NotFound_ReturnsNotFound() { - // Arrange - var handler = new TestHandler(async (message) => - { - Assert.AreEqual(HttpMethod.Post, message.Method); - var content = await message.Content.ReadAsStringAsync(); - Assert.AreEqual("{\"foo\":\"bar\"}", content); - return new HttpResponseMessage(HttpStatusCode.Created) - { - Content = new StringContent("CREATED") - }; - }); - var httpClient = new HttpClient(handler); - _httpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); - - _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig - { - ManageNemsSubscriptionDataServiceURL = "http://downstream/api/NemsSubscriptionDataService", - CaasToMailbox = "TEST_TO", - CaasFromMailbox = "TEST_FROM" - }); - - var req = _setupRequest.Setup("{\"foo\":\"bar\"}", new NameValueCollection(), HttpMethod.Post); - - // Act - var res = await _sut.NemsSubscriptionDataService(req.Object, "abc"); - var body = await AssertionHelper.ReadResponseBodyAsync(res); - - // Assert - Assert.AreEqual(HttpStatusCode.Created, res.StatusCode); - Assert.AreEqual("CREATED", body); - } + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Get); + _nemsAccessor + .Setup(a => a.GetSingle(It.IsAny>>() )) + .ReturnsAsync((NemsSubscription?)null); - private sealed class TestHandler : HttpMessageHandler - { - private readonly Func> _handler; - public TestHandler(Func> handler) - { - _handler = handler; - } - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - return _handler(request); - } + var res = await _sut.CheckSubscriptionStatus(req.Object); + Assert.AreEqual(HttpStatusCode.NotFound, res.StatusCode); } } From fb44429831bf60628a84e20e624e5f3bc83227d2 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 28 Aug 2025 17:27:24 +0100 Subject: [PATCH 09/49] fix: added db configuration to core.yml for managecaassubscription function --- application/CohortManager/compose.core.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/application/CohortManager/compose.core.yaml b/application/CohortManager/compose.core.yaml index f262b694b0..07ffcc0b0d 100644 --- a/application/CohortManager/compose.core.yaml +++ b/application/CohortManager/compose.core.yaml @@ -314,6 +314,7 @@ services: - ManageNemsSubscriptionBaseURL=http://manage-nems-subscription:9081 - CaasToMailbox=${CAAS_SUBSCRIBE_TO_MAILBOX:-CAAS_TO} - CaasFromMailbox=${CAAS_SUBSCRIBE_FROM_MAILBOX:-CAAS_FROM} + - DtOsDatabaseConnectionString=Server=db,1433;Database=${DB_NAME};User Id=SA;Password=${PASSWORD};TrustServerCertificate=True ports: - "9084:9084" From 7be2593d8c258539ef9a90a706993d61965f839d Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 28 Aug 2025 17:35:31 +0100 Subject: [PATCH 10/49] feat: additional unit tests, catch around mesh logic --- .../ManageCaasSubscription.cs | 36 +++++++---- .../ManageCaasSubscriptionTests.cs | 64 +++++++++++++++++++ 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs index 3bf89b012a..7fb54a5026 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -40,24 +40,32 @@ public ManageCaasSubscription( [Function("Subscribe")] public async Task Subscribe([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) { - var nhsNumber = req.Query["nhsNumber"]; - if (!ValidationHelper.ValidateNHSNumber(nhsNumber)) + try { - return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); - } + var nhsNumber = req.Query["nhsNumber"]; + if (!ValidationHelper.ValidateNHSNumber(nhsNumber)) + { + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); + } - // Forward to MeshSendCaasSubscribeStub (Shared) - long.TryParse(nhsNumber, out var nhsNo); - var toMailbox = _config.Value.CaasToMailbox; - var fromMailbox = _config.Value.CaasFromMailbox; - if (string.IsNullOrWhiteSpace(toMailbox) || string.IsNullOrWhiteSpace(fromMailbox)) + // Forward to MeshSendCaasSubscribeStub (Shared) + long.TryParse(nhsNumber, out var nhsNo); + var toMailbox = _config.Value.CaasToMailbox; + var fromMailbox = _config.Value.CaasFromMailbox; + if (string.IsNullOrWhiteSpace(toMailbox) || string.IsNullOrWhiteSpace(fromMailbox)) + { + _logger.LogError("CAAS mailbox configuration missing. CaasToMailbox or CaasFromMailbox not set."); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "CAAS mailbox configuration missing."); + } + var messageId = await _meshSendCaasSubscribe.SendSubscriptionRequest(nhsNo, toMailbox, fromMailbox); + _logger.LogInformation("CAAS Subscribe forwarded to Mesh stub. NHS: {Nhs}, MessageId: {Msg}", nhsNo, messageId); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, $"Subscription request accepted. MessageId: {messageId}"); + } + catch (Exception ex) { - _logger.LogError("CAAS mailbox configuration missing. CaasToMailbox or CaasFromMailbox not set."); - return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "CAAS mailbox configuration missing."); + _logger.LogError(ex, "Error sending CAAS subscribe request"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "An error occurred while sending the CAAS subscription request."); } - var messageId = await _meshSendCaasSubscribe.SendSubscriptionRequest(nhsNo, toMailbox, fromMailbox); - _logger.LogInformation("CAAS Subscribe forwarded to Mesh stub. NHS: {Nhs}, MessageId: {Msg}", nhsNo, messageId); - return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, $"Subscription request accepted. MessageId: {messageId}"); } [Function("Unsubscribe")] diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index e319c981b7..6712cc4529 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -112,6 +112,14 @@ public async Task Unsubscribe_Valid_ReturnsOk() Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); } + [TestMethod] + public async Task Unsubscribe_Invalid_ReturnsBadRequest() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "" } }, HttpMethod.Post); + var res = await _sut.Unsubscribe(req.Object); + Assert.AreEqual(HttpStatusCode.BadRequest, res.StatusCode); + } + [TestMethod] public async Task CheckSubscriptionStatus_Valid_ReturnsOk() { @@ -120,6 +128,18 @@ public async Task CheckSubscriptionStatus_Valid_ReturnsOk() Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); } + [DataTestMethod] + [DataRow((string)null)] + [DataRow("")] + [DataRow("abc")] + [DataRow("12345")] + public async Task CheckSubscriptionStatus_InvalidInputs_ReturnsBadRequest(string nhsNumber) + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", nhsNumber } }, HttpMethod.Get); + var res = await _sut.CheckSubscriptionStatus(req.Object); + Assert.AreEqual(HttpStatusCode.BadRequest, res.StatusCode); + } + [TestMethod] public async Task DataService_ReturnsOk() { @@ -158,6 +178,18 @@ public async Task NemsSubscriptionDataService_ValidRequest_DelegatesToHandler() // Assert Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + _requestHandler.Verify(r => r.HandleRequest(It.Is(h => object.ReferenceEquals(h, req.Object)), "my-key"), Times.Once); + } + + [TestMethod] + public async Task NemsSubscriptionDataService_HandlerThrows_ReturnsInternalServerError() + { + var req = _setupRequest.Setup(null, new NameValueCollection(), HttpMethod.Get); + _requestHandler + .Setup(r => r.HandleRequest(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("boom")); + var res = await _sut.NemsSubscriptionDataService(req.Object, "key"); + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); } [TestMethod] @@ -171,4 +203,36 @@ public async Task CheckSubscriptionStatus_NotFound_ReturnsNotFound() var res = await _sut.CheckSubscriptionStatus(req.Object); Assert.AreEqual(HttpStatusCode.NotFound, res.StatusCode); } + + [TestMethod] + public async Task CheckSubscriptionStatus_AccessorThrows_ReturnsInternalServerError() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Get); + _nemsAccessor + .Setup(a => a.GetSingle(It.IsAny>>() )) + .ThrowsAsync(new Exception("db-error")); + var res = await _sut.CheckSubscriptionStatus(req.Object); + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + } + + [TestMethod] + public async Task Subscribe_MeshCalled_WithConfigMailboxes() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + _mesh.Verify(m => m.SendSubscriptionRequest(9000000009L, "TEST_TO", "TEST_FROM"), Times.Once); + } + + [TestMethod] + public async Task Subscribe_MeshThrows_ReturnsInternalServerError() + { + _mesh + .Setup(m => m.SendSubscriptionRequest(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("mesh-down")); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + } } From ce1689937edb7908fc39910743e8c1d2451325d5 Mon Sep 17 00:00:00 2001 From: Michael Clayson Date: Fri, 29 Aug 2025 09:22:45 +0100 Subject: [PATCH 11/49] integation tests --- .../Common/Mesh/MeshSendCaasSubscribe.cs | 15 +++++----- .../ParquetAsserts.cs | 29 +++++++++++++++++++ .../SendCaasSubscribeTests.cs | 10 +++---- 3 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 tests/integration-tests/MeshCaaSSubscribeIntegrationTests/ParquetAsserts.cs diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs index 408a68d62e..acd738d389 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs @@ -32,6 +32,8 @@ public async Task SendSubscriptionRequest(long nhsNumber, string toMailb ContentType = "application/octet-stream" }; + await File.WriteAllBytesAsync("Testpremesh.parquet", content); + var result = await _meshOutboxService.SendCompressedMessageAsync(fromMailbox, toMailbox, _config.SendCaasWorkflowId, file); if (!result.IsSuccessful) { @@ -50,19 +52,18 @@ private static byte[] CreateParquetFile(long nhsNumber) }; long[] nhsNumberList = { nhsNumber }; - using (var stream = new MemoryStream()) + using var stream = new MemoryStream(); + using var writer = new ManagedOutputStream(stream); + using (var file = new ParquetFileWriter(writer, columns)) { - - using var writer = new ManagedOutputStream(stream); - using var file = new ParquetFileWriter(writer, columns); using var rowGroup = file.AppendRowGroup(); using (var nhsNumberColumn = rowGroup.NextColumn().LogicalWriter()) { nhsNumberColumn.WriteBatch(nhsNumberList); } - var byteArrayContent = stream.ToArray(); - return byteArrayContent; - } + + return stream.ToArray(); + } } diff --git a/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/ParquetAsserts.cs b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/ParquetAsserts.cs new file mode 100644 index 0000000000..9b634e0c1f --- /dev/null +++ b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/ParquetAsserts.cs @@ -0,0 +1,29 @@ +namespace MeshCaaSSubscribeIntegrationTests; + +using System.IO; +using NHS.MESH.Client.Models; +using ParquetSharp; +using ParquetSharp.IO; +public static class ParquetAsserts +{ + public static void ContainsExpectedNhsNumber(byte[] parquetBytes, long expectedNhsNumber) + { + using var stream = new MemoryStream(parquetBytes); + using var reader = new ManagedRandomAccessFile(stream); + using var file = new ParquetFileReader(reader); + + var rowGroupCount = file.FileMetaData.NumRowGroups; + Assert.AreEqual(1, rowGroupCount, "Expected exactly 1 row group."); + + using var rowGroup = file.RowGroup(0); + + var columnCount = rowGroup.MetaData.NumColumns; + Assert.AreEqual(1, columnCount, "Expected exactly 1 column."); + + using var columnReader = rowGroup.Column(0).LogicalReader(); + var values = columnReader.ReadAll(checked((int)rowGroup.MetaData.NumRows)); + + Assert.AreEqual(1, values.Length, "Expected exactly 1 row."); + Assert.AreEqual(expectedNhsNumber, values[0], "NHS number does not match."); + } +} diff --git a/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs index b8d661bc26..ae7fe5735c 100644 --- a/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs +++ b/tests/integration-tests/MeshCaaSSubscribeIntegrationTests/SendCaasSubscribeTests.cs @@ -14,6 +14,7 @@ using System.Threading.Tasks; using NHS.MESH.Client.Helpers.ContentHelpers; +[TestCategory("Integration")] [TestClass] public sealed class SendCaasSubscribeTests { @@ -78,16 +79,15 @@ public async Task SendSubscriptionRequest_SendsNormalNhsNumber_ReturnsMessageId( // act - validate message recieved var getMessagesResult = await _meshInboxService.GetMessagesAsync(toMailbox); + // assert - File is in mesh Assert.IsTrue(getMessagesResult.Response.Messages.Contains(messageId)); + // act - download message and decompress message var message = await _meshInboxService.GetMessageByIdAsync(toMailbox, messageId); - var fileContent = GZIPHelpers.DeCompressBuffer(message.Response.FileAttachment.Content); - - - File.WriteAllBytes("test.parquet",fileContent); - + // asset - ensure message contains expected parquet file + ParquetAsserts.ContainsExpectedNhsNumber(fileContent, nhsNumber); } } From bb63bab588192188dd69df083f7d5378259c7c8f Mon Sep 17 00:00:00 2001 From: Michael Clayson Date: Fri, 29 Aug 2025 10:21:34 +0100 Subject: [PATCH 12/49] Mesh Polling --- .../ManageCaasSubscription.cs | 13 +++++- .../ManageCaasSubscription.csproj | 1 + .../ManageCaasSubscription/Program.cs | 1 + .../Shared/Common/Mesh/IMeshPoller.cs | 19 +++++++++ .../Shared/Common/Mesh/MeshPoller.cs | 42 +++++++++++++++++++ 5 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshPoller.cs create mode 100644 application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs index 7fb54a5026..7aa79dd8f6 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -20,6 +20,7 @@ public class ManageCaasSubscription private readonly IMeshSendCaasSubscribe _meshSendCaasSubscribe; private readonly IRequestHandler _requestHandler; private readonly IDataServiceAccessor _nemsSubscriptionAccessor; + private readonly IMeshPoller _meshPoller; public ManageCaasSubscription( ILogger logger, @@ -27,7 +28,8 @@ public ManageCaasSubscription( IOptions config, IMeshSendCaasSubscribe meshSendCaasSubscribe, IRequestHandler requestHandler, - IDataServiceAccessor nemsSubscriptionAccessor) + IDataServiceAccessor nemsSubscriptionAccessor, + IMeshPoller meshPoller) { _logger = logger; _createResponse = createResponse; @@ -35,6 +37,7 @@ public ManageCaasSubscription( _meshSendCaasSubscribe = meshSendCaasSubscribe; _requestHandler = requestHandler; _nemsSubscriptionAccessor = nemsSubscriptionAccessor; + _meshPoller = meshPoller; } [Function("Subscribe")] @@ -128,4 +131,12 @@ public async Task NemsSubscriptionDataService([HttpTrigger(Aut return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "An error occurred while processing the data service request."); } } + + [Function("PollMeshMailbox")] + public async Task RunAsync([TimerTrigger("59 23 * * *")] TimerInfo myTimer) + { + await _meshPoller.ExecuteHandshake(_config.Value.CaasFromMailbox); + } + + } diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj index 23425ce067..d58867ccbb 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.csproj @@ -12,6 +12,7 @@ + diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs index d79449791f..ef3aa71690 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs @@ -15,6 +15,7 @@ services.AddSingleton(); services.AddBasicHealthCheck("ManageCaasSubscription"); services.AddSingleton(); + services.AddScoped(); }) .AddDataServicesHandler() .AddHttpClient() diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshPoller.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshPoller.cs new file mode 100644 index 0000000000..7e62ab5bd2 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshPoller.cs @@ -0,0 +1,19 @@ +namespace Common; + +public interface IMeshPoller +{ + /// + /// Executes a mesh handshake to the given mailboxId + /// This should be executed every 24 hours for every mesh mailbox + /// + /// + /// boolean, True if successful + Task ExecuteHandshake(string mailboxId); + /// + /// Will check against some state object if the handshake has not been executed in the past 23h55 mins + /// + /// + /// + /// true is handshake should be executed, false if not + Task ShouldExecuteHandshake(string mailboxId, string configFileName); +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs new file mode 100644 index 0000000000..6660ea71a4 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs @@ -0,0 +1,42 @@ +namespace Common; + +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Model; +using NHS.MESH.Client.Contracts.Services; + +public class MeshPoller : IMeshPoller +{ + private readonly ILogger _logger; + private readonly IMeshOperationService _meshOperationService; + private readonly IBlobStorageHelper _blobStorageHelper; + + public MeshPoller(ILogger logger, IMeshOperationService meshOperationService) + { + _logger = logger; + _meshOperationService = meshOperationService; + } + + public async Task ShouldExecuteHandshake(string mailboxId, string configFileName) + { + await Task.CompletedTask; + throw new NotImplementedException("ShouldExecuteHandshake is not yet implemented"); + } + + public async Task ExecuteHandshake(string mailboxId) + { + var meshValidationResponse = await _meshOperationService.MeshHandshakeAsync(mailboxId); + + if (!meshValidationResponse.IsSuccessful) + { + _logger.LogError("Error While handshaking with MESH. ErrorCode: {ErrorCode}, ErrorDescription: {ErrorDescription}", meshValidationResponse.Error?.ErrorCode, meshValidationResponse.Error?.ErrorDescription); + return false; + } + + return true; + } + +} + + + From 2c9febceee2f38e7a096a008afc5a33add3953b1 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Fri, 29 Aug 2025 11:50:20 +0100 Subject: [PATCH 13/49] fix: addressed comments from previous PR --- .../ManageCaasSubscription.cs | 28 ++++---- .../ManageCaasSubscriptionConfig.cs | 7 +- .../ManageCaasSubscriptionTests.cs | 71 +++++++++++-------- 3 files changed, 58 insertions(+), 48 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs index 7aa79dd8f6..f39454fb37 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -11,12 +11,13 @@ namespace NHS.CohortManager.DemographicServices; using System.Text; using DataServices.Core; using Model; +using NHS.CohortManager.DemographicServices; public class ManageCaasSubscription { private readonly ILogger _logger; private readonly ICreateResponse _createResponse; - private readonly IOptions _config; + private readonly ManageCaasSubscriptionConfig _config; private readonly IMeshSendCaasSubscribe _meshSendCaasSubscribe; private readonly IRequestHandler _requestHandler; private readonly IDataServiceAccessor _nemsSubscriptionAccessor; @@ -33,7 +34,7 @@ public ManageCaasSubscription( { _logger = logger; _createResponse = createResponse; - _config = config; + _config = config.Value; _meshSendCaasSubscribe = meshSendCaasSubscribe; _requestHandler = requestHandler; _nemsSubscriptionAccessor = nemsSubscriptionAccessor; @@ -46,22 +47,17 @@ public async Task Subscribe([HttpTrigger(AuthorizationLevel.An try { var nhsNumber = req.Query["nhsNumber"]; - if (!ValidationHelper.ValidateNHSNumber(nhsNumber)) + if (!ValidationHelper.ValidateNHSNumber(nhsNumber!)) { return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); } // Forward to MeshSendCaasSubscribeStub (Shared) - long.TryParse(nhsNumber, out var nhsNo); - var toMailbox = _config.Value.CaasToMailbox; - var fromMailbox = _config.Value.CaasFromMailbox; - if (string.IsNullOrWhiteSpace(toMailbox) || string.IsNullOrWhiteSpace(fromMailbox)) - { - _logger.LogError("CAAS mailbox configuration missing. CaasToMailbox or CaasFromMailbox not set."); - return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "CAAS mailbox configuration missing."); - } + var nhsNo = long.Parse(nhsNumber!); + var toMailbox = _config.CaasToMailbox!; + var fromMailbox = _config.CaasFromMailbox!; var messageId = await _meshSendCaasSubscribe.SendSubscriptionRequest(nhsNo, toMailbox, fromMailbox); - _logger.LogInformation("CAAS Subscribe forwarded to Mesh stub. NHS: {Nhs}, MessageId: {Msg}", nhsNo, messageId); + _logger.LogInformation("CAAS Subscribe forwarded to Mesh stub. MessageId: {Msg}", messageId); return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, $"Subscription request accepted. MessageId: {messageId}"); } catch (Exception ex) @@ -75,12 +71,12 @@ public async Task Subscribe([HttpTrigger(AuthorizationLevel.An public async Task Unsubscribe([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) { var nhsNumber = req.Query["nhsNumber"]; - if (!ValidationHelper.ValidateNHSNumber(nhsNumber)) + if (!ValidationHelper.ValidateNHSNumber(nhsNumber!)) { return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); } - _logger.LogInformation("[CAAS-Stub] Unsubscribe called for NHS: {Nhs}", nhsNumber); + _logger.LogInformation("[CAAS-Stub] Unsubscribe called"); return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, "Stub: CAAS subscription would be removed."); } @@ -93,7 +89,7 @@ public async Task CheckSubscriptionStatus([HttpTrigger(Authori string? nhsNumber = req.Query["nhsNumber"]; - if (!ValidationHelper.ValidateNHSNumber(nhsNumber)) + if (!ValidationHelper.ValidateNHSNumber(nhsNumber!)) { _logger.LogError("NHS number is required and must be valid format"); return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); @@ -135,7 +131,7 @@ public async Task NemsSubscriptionDataService([HttpTrigger(Aut [Function("PollMeshMailbox")] public async Task RunAsync([TimerTrigger("59 23 * * *")] TimerInfo myTimer) { - await _meshPoller.ExecuteHandshake(_config.Value.CaasFromMailbox); + await _meshPoller.ExecuteHandshake(_config.CaasFromMailbox!); } diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs index 03bf694158..6991b2d26e 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs @@ -1,7 +1,8 @@ namespace NHS.CohortManager.DemographicServices; +using System.ComponentModel.DataAnnotations; + // Minimal config object for ManageCaasSubscription. -// Intentionally no [Required] attributes so binding does not fail if unset. public class ManageCaasSubscriptionConfig { // Optional URL for pass-through to the existing NEMS data service @@ -12,8 +13,10 @@ public class ManageCaasSubscriptionConfig // Example: http://manage-nems-subscription:9081 public string? ManageNemsSubscriptionBaseURL { get; set; } - // Optional CAAS mailboxes for the subscribe stub + // Required CAAS mailboxes for the subscribe flow + [Required] public string? CaasToMailbox { get; set; } + [Required] public string? CaasFromMailbox { get; set; } // Controls whether shared implementations should use stubbed behavior diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index 6712cc4529..d37e5b8194 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -13,6 +13,8 @@ namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; using System.Net.Http; using DataServices.Core; using Model; +using System.ComponentModel.DataAnnotations; +using System.Linq; [TestClass] public class ManageCaasSubscriptionTests @@ -25,6 +27,7 @@ public class ManageCaasSubscriptionTests private readonly Mock _mesh = new(); private readonly Mock> _requestHandler = new(); private readonly Mock> _nemsAccessor = new(); + private readonly Mock _meshPoller = new(); public ManageCaasSubscriptionTests() { @@ -55,7 +58,8 @@ public ManageCaasSubscriptionTests() _config.Object, _mesh.Object, _requestHandler.Object, - _nemsAccessor.Object + _nemsAccessor.Object, + _meshPoller.Object ); } @@ -75,35 +79,6 @@ public async Task Subscribe_Invalid_ReturnsBadRequest() Assert.AreEqual(HttpStatusCode.BadRequest, res.StatusCode); } - [TestMethod] - public async Task Subscribe_MissingMailboxes_ReturnsInternalServerError() - { - // Arrange: missing config values - _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig - { - ManageNemsSubscriptionDataServiceURL = null, - CaasToMailbox = null, - CaasFromMailbox = null - }); - - var sutMissing = new ManageCaasSubscription( - _logger.Object, - _createResponse, - _config.Object, - _mesh.Object, - _requestHandler.Object, - _nemsAccessor.Object - ); - - var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); - - // Act - var res = await sutMissing.Subscribe(req.Object); - - // Assert - Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); - } - [TestMethod] public async Task Unsubscribe_Valid_ReturnsOk() { @@ -235,4 +210,40 @@ public async Task Subscribe_MeshThrows_ReturnsInternalServerError() var res = await _sut.Subscribe(req.Object); Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); } + + [TestMethod] + public async Task PollMeshMailbox_UsesConfigFromMailbox() + { + // Ensure config has expected mailbox + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig + { + CaasFromMailbox = "TEST_FROM", + CaasToMailbox = "TEST_TO" + }); + + var sut = new ManageCaasSubscription( + _logger.Object, + _createResponse, + _config.Object, + _mesh.Object, + _requestHandler.Object, + _nemsAccessor.Object, + _meshPoller.Object + ); + + await sut.RunAsync(null); + _meshPoller.Verify(p => p.ExecuteHandshake("TEST_FROM"), Times.Once); + } + + [TestMethod] + public void Config_MissingMailboxes_FailsValidation() + { + var cfg = new ManageCaasSubscriptionConfig(); + var context = new ValidationContext(cfg); + var results = new System.Collections.Generic.List(); + var isValid = Validator.TryValidateObject(cfg, context, results, validateAllProperties: true); + Assert.IsFalse(isValid); + Assert.IsTrue(results.Any(r => r.MemberNames.Contains("CaasToMailbox"))); + Assert.IsTrue(results.Any(r => r.MemberNames.Contains("CaasFromMailbox"))); + } } From a098fb7c0036e920c20ae63a40eec79ee1a57987 Mon Sep 17 00:00:00 2001 From: Michael Clayson Date: Fri, 29 Aug 2025 13:04:28 +0100 Subject: [PATCH 14/49] mesh mailbox extension --- .../RetrieveMeshFile/Program.cs | 1 - .../Common/Extensions/MeshMailboxExtension.cs | 78 +++++++++++++++++++ .../Shared/Common/Mesh/MeshConfig.cs | 20 +++++ .../Common/Utilities}/CertificateHelper.cs | 8 +- 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs create mode 100644 application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs rename application/CohortManager/src/Functions/{CaasIntegration/RetrieveMeshFile => Shared/Common/Utilities}/CertificateHelper.cs (93%) diff --git a/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/Program.cs b/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/Program.cs index 3ec991369b..5c970c552b 100644 --- a/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/Program.cs +++ b/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/Program.cs @@ -10,7 +10,6 @@ using NHS.Screening.RetrieveMeshFile; using HealthChecks.Extensions; using Azure.Security.KeyVault.Secrets; -using NHS.CohortManager.CaasIntegrationService; var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); diff --git a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs new file mode 100644 index 0000000000..fca9099934 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs @@ -0,0 +1,78 @@ +namespace Common; + +using System.Security.Cryptography.X509Certificates; +using Azure.Identity; +using Azure.Security.KeyVault.Certificates; +using Azure.Security.KeyVault.Secrets; +using Hl7.Fhir.Model.CdsHooks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NHS.MESH.Client; +using NHS.MESH.Client.Configuration; +public static class MeshMailboxExtension +{ + private static ILogger _logger; + public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshConfig config) + { + ILoggerFactory factory = LoggerFactory.Create(builder => + { + builder.AddConsole(); // or AddDebug(), AddEventSourceLogger(), etc. + builder.AddApplicationInsights(); + }); + _logger = factory.CreateLogger("MeshMailboxExtension"); + + hostBuilder.ConfigureServices(async services => + { + var meshClientBuilder = services.AddMeshClient(_ => + { + _.MeshApiBaseUrl = config.MeshApiBaseUrl; + _.BypassServerCertificateValidation = config.BypassServerCertificateValidation; + }); + + foreach (var mailbox in config.MailboxConfigs) + { + var cert = await GetCertificate(mailbox.MeshKeyName, mailbox.MeshKeyPassword, config.KeyVaultConnectionString); + var serverSideCerts = await GetCACertificates(config.MeshCACertName, config.KeyVaultConnectionString); + meshClientBuilder.AddMailbox(mailbox.MailboxId, new MailboxConfiguration + { + Password = mailbox.MeshPassword, + SharedKey = mailbox.SharedKey, + Cert = cert, + serverSideCertCollection = serverSideCerts + }); + } + + services = meshClientBuilder.Build(); + + }); + + return hostBuilder; + } + + private static async Task GetCertificate(string meshKeyName, string? meshKeyPassphrase, string? keyVaultConnectionString) + { + if (string.IsNullOrEmpty(keyVaultConnectionString)) + { + _logger.LogInformation("Pulling Mesh Certificate from local File"); + return new X509Certificate2(meshKeyName!, meshKeyPassphrase); + } + + _logger.LogInformation("Pulling Mesh Certificate from KeyVault"); + var certClient = new CertificateClient(vaultUri: new Uri(keyVaultConnectionString), credential: new DefaultAzureCredential()); + var certificate = await certClient.DownloadCertificateAsync(meshKeyName); + return certificate.Value; + } + + public static async Task GetCACertificates(string meshCertName, string? keyVaultConnectionString) + { + if (string.IsNullOrEmpty(keyVaultConnectionString)) + { + string certsString = await File.ReadAllTextAsync(meshCertName); + return CertificateHelper.GetCertificatesFromString(certsString); + } + var secretClient = new SecretClient(vaultUri: new Uri(keyVaultConnectionString), credential: new DefaultAzureCredential()); + string base64Cert = secretClient.GetSecret(meshCertName).Value.Value; + return CertificateHelper.GetCertificatesFromString(base64Cert); + + } +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs new file mode 100644 index 0000000000..c650170d6f --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs @@ -0,0 +1,20 @@ +namespace Common; + +public class MeshConfig +{ + public string? KeyVaultConnectionString { get; set; } + public string? MeshCACertName { get; set; } + public bool BypassServerCertificateValidation { get; set; } = false; + public required string MeshApiBaseUrl { get; set; } + public required List MailboxConfigs { get; set; } +} + +public class MailboxConfig +{ + public required string MailboxId { get; set; } + public string? MeshKeyName { get; set; } + public string? MeshKeyPassword { get; set; } + public required string MeshPassword { get; set; } + public required string SharedKey { get; set; } + +} diff --git a/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/CertificateHelper.cs b/application/CohortManager/src/Functions/Shared/Common/Utilities/CertificateHelper.cs similarity index 93% rename from application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/CertificateHelper.cs rename to application/CohortManager/src/Functions/Shared/Common/Utilities/CertificateHelper.cs index b44048dcb3..2e79fa1a53 100644 --- a/application/CohortManager/src/Functions/CaasIntegration/RetrieveMeshFile/CertificateHelper.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Utilities/CertificateHelper.cs @@ -1,4 +1,4 @@ -namespace NHS.CohortManager.CaasIntegrationService; +namespace Common; using System.Security.Cryptography.X509Certificates; @@ -23,9 +23,9 @@ public static X509Certificate2Collection GetCertificatesFromString(string certif return new X509Certificate2(Convert.FromBase64String(base64)); }) .ToArray(); - + certs.AddRange(pemCerts); - + return certs; } -} \ No newline at end of file +} From 9e6958fe072ae9f54881ea2014cbebed3297513a Mon Sep 17 00:00:00 2001 From: Michael Clayson Date: Fri, 29 Aug 2025 16:07:26 +0100 Subject: [PATCH 15/49] mesh integrated --- .../ManageCaasSubscriptionConfig.cs | 13 +++++-- .../ManageCaasSubscription/Program.cs | 27 +++++++++++++- .../Common/Extensions/MeshMailboxExtension.cs | 35 ++++++++++++------- .../Shared/Common/Mesh/MeshConfig.cs | 2 +- .../Shared/Common/Mesh/MeshPoller.cs | 1 - 5 files changed, 59 insertions(+), 19 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs index 03bf694158..f8d655c85a 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs @@ -11,10 +11,17 @@ public class ManageCaasSubscriptionConfig // Optional base URL to forward selected endpoints (e.g., CheckSubscriptionStatus) // Example: http://manage-nems-subscription:9081 public string? ManageNemsSubscriptionBaseURL { get; set; } + public required string MeshApiBaseUrl { get; set; } + public string? KeyVaultConnectionString { get; set; } + public bool BypassServerCertificateValidation { get; set; } = false; + public string? MeshCACertName { get; set; } + public string? MeshCaasKeyName { get; set; } + public string? MeshCaasKeyPassword { get; set; } + public string? MeshCaasPassword { get; set; } + public required string MeshCaasSharedKey { get; set; } + public required string CaasToMailbox { get; set; } + public required string CaasFromMailbox { get; set; } - // Optional CAAS mailboxes for the subscribe stub - public string? CaasToMailbox { get; set; } - public string? CaasFromMailbox { get; set; } // Controls whether shared implementations should use stubbed behavior public bool IsStubbed { get; set; } = true; diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs index ef3aa71690..7c1c3545ee 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs @@ -10,11 +10,36 @@ var host = new HostBuilder() .ConfigureFunctionsWebApplication() .AddConfiguration(out ManageCaasSubscriptionConfig config) + .AddMeshMailboxes(new MeshConfig + { + MeshApiBaseUrl = config.MeshApiBaseUrl, + KeyVaultConnectionString = config.KeyVaultConnectionString, + BypassServerCertificateValidation = config.BypassServerCertificateValidation, + MailboxConfigs = new List + { + new MailboxConfig + { + MailboxId = config.CaasFromMailbox, + MeshKeyName = config.MeshCaasKeyName, + MeshKeyPassword = config.MeshCaasKeyPassword, + MeshPassword = config.MeshCaasPassword, + SharedKey = config.MeshCaasSharedKey + + } + } + }) .ConfigureServices(services => { services.AddSingleton(); services.AddBasicHealthCheck("ManageCaasSubscription"); - services.AddSingleton(); + if (config.IsStubbed) + { + services.AddSingleton(); + } + else + { + services.AddScoped(); + } services.AddScoped(); }) .AddDataServicesHandler() diff --git a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs index fca9099934..ca20223e7c 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs @@ -49,30 +49,39 @@ public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshC return hostBuilder; } - private static async Task GetCertificate(string meshKeyName, string? meshKeyPassphrase, string? keyVaultConnectionString) + private static async Task GetCertificate(string meshKeyName, string? meshKeyPassphrase, string? keyVaultConnectionString) { - if (string.IsNullOrEmpty(keyVaultConnectionString)) + if (!string.IsNullOrEmpty(keyVaultConnectionString)) + { + _logger.LogInformation("Pulling Mesh Certificate from KeyVault"); + var certClient = new CertificateClient(vaultUri: new Uri(keyVaultConnectionString), credential: new DefaultAzureCredential()); + var certificate = await certClient.DownloadCertificateAsync(meshKeyName); + return certificate.Value; + } + + if (!string.IsNullOrEmpty(meshKeyName) || Path.Exists(meshKeyName)) { _logger.LogInformation("Pulling Mesh Certificate from local File"); return new X509Certificate2(meshKeyName!, meshKeyPassphrase); } - - _logger.LogInformation("Pulling Mesh Certificate from KeyVault"); - var certClient = new CertificateClient(vaultUri: new Uri(keyVaultConnectionString), credential: new DefaultAzureCredential()); - var certificate = await certClient.DownloadCertificateAsync(meshKeyName); - return certificate.Value; + return null; } - public static async Task GetCACertificates(string meshCertName, string? keyVaultConnectionString) + + public static async Task GetCACertificates(string meshCertName, string? keyVaultConnectionString) { - if (string.IsNullOrEmpty(keyVaultConnectionString)) + if (!string.IsNullOrEmpty(keyVaultConnectionString)) { + var secretClient = new SecretClient(vaultUri: new Uri(keyVaultConnectionString), credential: new DefaultAzureCredential()); + string base64Cert = secretClient.GetSecret(meshCertName).Value.Value; + return CertificateHelper.GetCertificatesFromString(base64Cert); + } + if (!string.IsNullOrEmpty(meshCertName) || Path.Exists(meshCertName)) + { + string certsString = await File.ReadAllTextAsync(meshCertName); return CertificateHelper.GetCertificatesFromString(certsString); } - var secretClient = new SecretClient(vaultUri: new Uri(keyVaultConnectionString), credential: new DefaultAzureCredential()); - string base64Cert = secretClient.GetSecret(meshCertName).Value.Value; - return CertificateHelper.GetCertificatesFromString(base64Cert); - + return null; } } diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs index c650170d6f..e1abdfbac9 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs @@ -14,7 +14,7 @@ public class MailboxConfig public required string MailboxId { get; set; } public string? MeshKeyName { get; set; } public string? MeshKeyPassword { get; set; } - public required string MeshPassword { get; set; } + public string? MeshPassword { get; set; } public required string SharedKey { get; set; } } diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs index 6660ea71a4..840f02b09e 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs @@ -9,7 +9,6 @@ public class MeshPoller : IMeshPoller { private readonly ILogger _logger; private readonly IMeshOperationService _meshOperationService; - private readonly IBlobStorageHelper _blobStorageHelper; public MeshPoller(ILogger logger, IMeshOperationService meshOperationService) { From 94f4078bb4d6e3dd615613900c478bffe5857f7c Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Fri, 29 Aug 2025 18:49:52 +0100 Subject: [PATCH 16/49] feat: Subscription source addition to nems subscription data table --- .../CaasNemsSubscriptionAccessor.cs | 54 +++++++++++++++++++ .../ManageCaasSubscription/Program.cs | 6 +++ .../NemsSubscriptionManager.cs | 3 +- ...0250829_add_subscription_source_to_nems.cs | 27 ++++++++++ .../Shared/Model/EFModels/NemsSubscription.cs | 3 ++ .../Shared/Model/Enums/SubscriptionSource.cs | 8 +++ 6 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/CaasNemsSubscriptionAccessor.cs create mode 100644 application/CohortManager/src/Functions/Shared/DataServices.Migrations/20250829_add_subscription_source_to_nems.cs create mode 100644 application/CohortManager/src/Functions/Shared/Model/Enums/SubscriptionSource.cs diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/CaasNemsSubscriptionAccessor.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/CaasNemsSubscriptionAccessor.cs new file mode 100644 index 0000000000..ca050338ce --- /dev/null +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/CaasNemsSubscriptionAccessor.cs @@ -0,0 +1,54 @@ +namespace NHS.CohortManager.DemographicServices; + +using System.Linq.Expressions; +using DataServices.Core; +using Model; + +public class CaasNemsSubscriptionAccessor : IDataServiceAccessor +{ + private readonly IDataServiceAccessor _inner; + public CaasNemsSubscriptionAccessor(IDataServiceAccessor inner) + { + _inner = inner; + } + + public Task GetSingle(Expression> predicate) + => _inner.GetSingle(predicate); + + public Task> GetRange(Expression> predicates) + => _inner.GetRange(predicates); + + public Task Remove(Expression> predicate) + => _inner.Remove(predicate); + + public async Task InsertSingle(NemsSubscription entity) + { + if (entity.SubscriptionSource == null) + { + entity.SubscriptionSource = SubscriptionSource.MESH; + } + return await _inner.InsertSingle(entity); + } + + public async Task InsertMany(IEnumerable entities) + { + foreach (var e in entities) + { + if (e.SubscriptionSource == null) + { + e.SubscriptionSource = SubscriptionSource.MESH; + } + } + return await _inner.InsertMany(entities); + } + + public async Task Update(NemsSubscription entity, Expression> predicate) + { + if (entity.SubscriptionSource == null) + { + entity.SubscriptionSource = SubscriptionSource.MESH; + } + return await _inner.Update(entity, predicate); + } +} + diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs index ef3aa71690..557bf921b3 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs @@ -16,6 +16,12 @@ services.AddBasicHealthCheck("ManageCaasSubscription"); services.AddSingleton(); services.AddScoped(); + // Wrap NemsSubscription accessor to enforce SubscriptionSource = MESH when missing + services.AddScoped>(sp => + { + var inner = sp.GetRequiredService>(); + return new NHS.CohortManager.DemographicServices.CaasNemsSubscriptionAccessor(inner); + }); }) .AddDataServicesHandler() .AddHttpClient() diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageNemsSubscription/NemsSubscriptionManager.cs b/application/CohortManager/src/Functions/DemographicServices/ManageNemsSubscription/NemsSubscriptionManager.cs index 6b63bd561f..dae9521057 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageNemsSubscription/NemsSubscriptionManager.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageNemsSubscription/NemsSubscriptionManager.cs @@ -234,7 +234,8 @@ public async Task SaveSubscriptionInDatabase(string nhsNumber, string subs { SubscriptionId = subscriptionId, NhsNumber = Convert.ToInt64(nhsNumber), - RecordInsertDateTime = DateTime.UtcNow + RecordInsertDateTime = DateTime.UtcNow, + SubscriptionSource = SubscriptionSource.NEMS }; bool subscriptionCreated = await _nemsSubscriptionAccessor.InsertSingle(subscription); diff --git a/application/CohortManager/src/Functions/Shared/DataServices.Migrations/20250829_add_subscription_source_to_nems.cs b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/20250829_add_subscription_source_to_nems.cs new file mode 100644 index 0000000000..4ab43b7464 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/20250829_add_subscription_source_to_nems.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace DataServices.Database.Migrations +{ + public partial class add_subscription_source_to_nems : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SUBSCRIPTION_SOURCE", + schema: "dbo", + table: "NEMS_SUBSCRIPTION", + type: "int", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SUBSCRIPTION_SOURCE", + schema: "dbo", + table: "NEMS_SUBSCRIPTION"); + } + } +} + diff --git a/application/CohortManager/src/Functions/Shared/Model/EFModels/NemsSubscription.cs b/application/CohortManager/src/Functions/Shared/Model/EFModels/NemsSubscription.cs index 1190b27ff1..fbb942323e 100644 --- a/application/CohortManager/src/Functions/Shared/Model/EFModels/NemsSubscription.cs +++ b/application/CohortManager/src/Functions/Shared/Model/EFModels/NemsSubscription.cs @@ -18,4 +18,7 @@ public class NemsSubscription [Column("RECORD_UPDATE_DATETIME", TypeName = "datetime")] public DateTime? RecordUpdateDateTime { get; set; } + + [Column("SUBSCRIPTION_SOURCE", TypeName = "int")] + public SubscriptionSource? SubscriptionSource { get; set; } } diff --git a/application/CohortManager/src/Functions/Shared/Model/Enums/SubscriptionSource.cs b/application/CohortManager/src/Functions/Shared/Model/Enums/SubscriptionSource.cs new file mode 100644 index 0000000000..3e7efa9d2f --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Model/Enums/SubscriptionSource.cs @@ -0,0 +1,8 @@ +namespace Model; + +public enum SubscriptionSource +{ + NEMS = 1, + MESH = 2 +} + From 9ab4a27ece6458ef3e6397310b844e4f36df8d65 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Fri, 29 Aug 2025 18:59:31 +0100 Subject: [PATCH 17/49] feat: insert record into nems sub table when new sub created --- .../ManageCaasSubscription.cs | 16 ++++++++++++++++ .../ManageCaasSubscriptionTests.cs | 11 +++++++++++ 2 files changed, 27 insertions(+) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs index f39454fb37..d05dabb019 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -57,6 +57,22 @@ public async Task Subscribe([HttpTrigger(AuthorizationLevel.An var toMailbox = _config.CaasToMailbox!; var fromMailbox = _config.CaasFromMailbox!; var messageId = await _meshSendCaasSubscribe.SendSubscriptionRequest(nhsNo, toMailbox, fromMailbox); + + // Save a record to NEMS_SUBSCRIPTION table with source = MESH + var record = new NemsSubscription + { + SubscriptionId = messageId, + NhsNumber = nhsNo, + RecordInsertDateTime = DateTime.UtcNow, + SubscriptionSource = SubscriptionSource.MESH + }; + var saved = await _nemsSubscriptionAccessor.InsertSingle(record); + if (!saved) + { + _logger.LogError("Failed to write CAAS subscription record to database"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Failed to save subscription record."); + } + _logger.LogInformation("CAAS Subscribe forwarded to Mesh stub. MessageId: {Msg}", messageId); return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, $"Subscription request accepted. MessageId: {messageId}"); } diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index d37e5b8194..30af90f4bf 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -193,10 +193,12 @@ public async Task CheckSubscriptionStatus_AccessorThrows_ReturnsInternalServerEr [TestMethod] public async Task Subscribe_MeshCalled_WithConfigMailboxes() { + _nemsAccessor.Setup(a => a.InsertSingle(It.IsAny())).ReturnsAsync(true); var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); var res = await _sut.Subscribe(req.Object); Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); _mesh.Verify(m => m.SendSubscriptionRequest(9000000009L, "TEST_TO", "TEST_FROM"), Times.Once); + _nemsAccessor.Verify(a => a.InsertSingle(It.Is(n => n.NhsNumber == 9000000009L && n.SubscriptionSource == SubscriptionSource.MESH && !string.IsNullOrEmpty(n.SubscriptionId))), Times.Once); } [TestMethod] @@ -211,6 +213,15 @@ public async Task Subscribe_MeshThrows_ReturnsInternalServerError() Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); } + [TestMethod] + public async Task Subscribe_DBInsertFails_ReturnsInternalServerError() + { + _nemsAccessor.Setup(a => a.InsertSingle(It.IsAny())).ReturnsAsync(false); + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + } + [TestMethod] public async Task PollMeshMailbox_UsesConfigFromMailbox() { From d1b8ee4c455cdb051a934b7917cec468d64b98f2 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Fri, 29 Aug 2025 21:54:29 +0100 Subject: [PATCH 18/49] fix: complete proper ef migration for new subscription source data table enumerable --- ...dd_subscription_source_to_nems.Designer.cs | 1064 +++++++++++++++++ ...180608_add_subscription_source_to_nems.cs} | 11 +- .../DataServicesContextModelSnapshot.cs | 4 + 3 files changed, 1075 insertions(+), 4 deletions(-) create mode 100644 application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.Designer.cs rename application/CohortManager/src/Functions/Shared/DataServices.Migrations/{20250829_add_subscription_source_to_nems.cs => Migrations/20250829180608_add_subscription_source_to_nems.cs} (77%) diff --git a/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.Designer.cs b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.Designer.cs new file mode 100644 index 0000000000..d780f4da1e --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.Designer.cs @@ -0,0 +1,1064 @@ +// +using System; +using DataServices.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DataServices.Migrations.Migrations +{ + [DbContext(typeof(DataServicesContext))] + [Migration("20250829180608_add_subscription_source_to_nems")] + partial class add_subscription_source_to_nems + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Model.BsSelectGpPractice", b => + { + b.Property("GpPracticeCode") + .HasColumnType("nvarchar(450)") + .HasColumnName("GP_PRACTICE_CODE"); + + b.Property("AuditCreatedTimeStamp") + .HasColumnType("datetime") + .HasColumnName("AUDIT_CREATED_TIMESTAMP"); + + b.Property("AuditId") + .HasColumnType("decimal(18,2)") + .HasColumnName("AUDIT_ID"); + + b.Property("AuditLastUpdatedTimeStamp") + .HasColumnType("datetime") + .HasColumnName("AUDIT_LAST_MODIFIED_TIMESTAMP"); + + b.Property("AuditText") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("AUDIT_TEXT"); + + b.Property("BsoCode") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("BSO"); + + b.Property("CountryCategory") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("COUNTRY_CATEGORY"); + + b.HasKey("GpPracticeCode"); + + b.ToTable("BS_SELECT_GP_PRACTICE_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.BsSelectOutCode", b => + { + b.Property("Outcode") + .HasColumnType("nvarchar(450)") + .HasColumnName("OUTCODE"); + + b.Property("AuditCreatedTimeStamp") + .HasColumnType("datetime") + .HasColumnName("AUDIT_CREATED_TIMESTAMP"); + + b.Property("AuditId") + .HasColumnType("decimal(18,2)") + .HasColumnName("AUDIT_ID"); + + b.Property("AuditLastModifiedTimeStamp") + .HasColumnType("datetime") + .HasColumnName("AUDIT_LAST_MODIFIED_TIMESTAMP"); + + b.Property("AuditText") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("AUDIT_TEXT"); + + b.Property("BSO") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("BSO"); + + b.HasKey("Outcode"); + + b.ToTable("BS_SELECT_OUTCODE_MAPPING_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.BsSelectRequestAudit", b => + { + b.Property("RequestId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasColumnName("REQUEST_ID"); + + b.Property("CreatedDateTime") + .HasColumnType("datetime") + .HasColumnName("CREATED_DATETIME"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)") + .HasColumnName("STATUS_CODE"); + + b.HasKey("RequestId"); + + b.ToTable("BS_SELECT_REQUEST_AUDIT", "dbo"); + }); + + modelBuilder.Entity("Model.BsoOrganisation", b => + { + b.Property("BsoOrganisationId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("BSO_ORGANISATION_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("BsoOrganisationId")); + + b.Property("AddressLine1") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("ADDRESS_LINE_1"); + + b.Property("AddressLine2") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("ADDRESS_LINE_2"); + + b.Property("AddressLine3") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("ADDRESS_LINE_3"); + + b.Property("AddressLine4") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("ADDRESS_LINE_4"); + + b.Property("AddressLine5") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("ADDRESS_LINE_5"); + + b.Property("AdminEmailAddress") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADMIN_EMAIL_ADDRESS"); + + b.Property("AutoBatchLastRun") + .HasColumnType("datetime2") + .HasColumnName("AUTO_BATCH_LAST_RUN"); + + b.Property("AutoBatchMaxDateTimeProcessed") + .HasColumnType("datetime2") + .HasColumnName("AUTO_BATCH_MAX_DATE_TIME_PROCESSED"); + + b.Property("BsoOrganisationCode") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)") + .HasColumnName("BSO_ORGANISATION_CODE"); + + b.Property("BsoOrganisationName") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)") + .HasColumnName("BSO_ORGANISATION_NAME"); + + b.Property("BsoRecallInterval") + .HasColumnType("tinyint") + .HasColumnName("BSO_RECALL_INTERVAL"); + + b.Property("BsoRegionId") + .HasColumnType("int") + .HasColumnName("BSO_REGION_ID"); + + b.Property("EmailAddress") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("EMAIL_ADDRESS"); + + b.Property("Extension") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("EXTENSION"); + + b.Property("FailSafeDateOfMonth") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_DATE_OF_MONTH"); + + b.Property("FailSafeLastRun") + .HasColumnType("datetime2") + .HasColumnName("FAILSAFE_LAST_RUN"); + + b.Property("FailSafeMaxAgeMonths") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_MAX_AGE_MONTHS"); + + b.Property("FailSafeMaxAgeYears") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_MAX_AGE_YEARS"); + + b.Property("FailSafeMinAgeMonths") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_MIN_AGE_MONTHS"); + + b.Property("FailSafeMinAgeYears") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_MIN_AGE_YEARS"); + + b.Property("FailSafeMonths") + .HasColumnType("tinyint") + .HasColumnName("FAILSAFE_MONTHS"); + + b.Property("FaxNumber") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("FAX_NUMBER"); + + b.Property("FoaMaxOffset") + .HasColumnType("tinyint") + .HasColumnName("FOA_MAX_OFFSET"); + + b.Property("IepDetails") + .HasColumnType("nvarchar(max)") + .HasColumnName("IEP_DETAILS"); + + b.Property("IgnoreEarlyRecall") + .HasColumnType("bit") + .HasColumnName("IGNORE_EARLY_RECALL"); + + b.Property("IgnoreGPReferrals") + .HasColumnType("bit") + .HasColumnName("IGNORE_GP_REFERRALS"); + + b.Property("IgnoreSelfReferrals") + .HasColumnType("bit") + .HasColumnName("IGNORE_SELF_REFERRALS"); + + b.Property("InviteListSequenceNumber") + .HasColumnType("int") + .HasColumnName("INVITE_LIST_SEQUENCE_NUMBER"); + + b.Property("IsActive") + .HasColumnType("bit") + .HasColumnName("IS_ACTIVE"); + + b.Property("IsAgex") + .HasColumnType("bit") + .HasColumnName("IS_AGEX"); + + b.Property("IsAgexActive") + .HasColumnType("bit") + .HasColumnName("IS_AGEX_ACTIVE"); + + b.Property("LinkCode") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("LINK_CODE"); + + b.Property("LowerAgeRange") + .HasColumnType("tinyint") + .HasColumnName("LOWER_AGE_RANGE"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)") + .HasColumnName("NOTES"); + + b.Property("OutgoingTransferNumber") + .HasColumnType("int") + .HasColumnName("OUTGOING_TRANSFER_NUMBER"); + + b.Property("PostCode") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasColumnName("POSTCODE"); + + b.Property("RispRecallInterval") + .HasColumnType("tinyint") + .HasColumnName("RISP_RECALL_INTERVAL"); + + b.Property("RlpDateEnabled") + .HasColumnType("datetime2") + .HasColumnName("RLP_DATE_ENABLED"); + + b.Property("SafetyPeriod") + .HasColumnType("tinyint") + .HasColumnName("SAFETY_PERIOD"); + + b.Property("TelephoneNumber") + .HasMaxLength(18) + .HasColumnType("nvarchar(18)") + .HasColumnName("TELEPHONE_NUMBER"); + + b.Property("TransactionAppDateTime") + .HasColumnType("datetime2") + .HasColumnName("TRANSACTION_APP_DATE_TIME"); + + b.Property("TransactionDbDateTime") + .HasColumnType("datetime2") + .HasColumnName("TRANSACTION_DB_DATE_TIME"); + + b.Property("TransactionId") + .HasColumnType("int") + .HasColumnName("TRANSACTION_ID"); + + b.Property("TransactionUserOrgRoleId") + .HasColumnType("int") + .HasColumnName("TRANSACTION_USER_ORG_ROLE_ID"); + + b.Property("UpperAgeRange") + .HasColumnType("tinyint") + .HasColumnName("UPPER_AGE_RANGE"); + + b.HasKey("BsoOrganisationId"); + + b.ToTable("BSO_ORGANISATIONS", "dbo"); + }); + + modelBuilder.Entity("Model.CohortDistribution", b => + { + b.Property("CohortDistributionId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("BS_COHORT_DISTRIBUTION_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CohortDistributionId")); + + b.Property("AddressLine1") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADDRESS_LINE_1"); + + b.Property("AddressLine2") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADDRESS_LINE_2"); + + b.Property("AddressLine3") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADDRESS_LINE_3"); + + b.Property("AddressLine4") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADDRESS_LINE_4"); + + b.Property("AddressLine5") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("ADDRESS_LINE_5"); + + b.Property("CurrentPosting") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("CURRENT_POSTING"); + + b.Property("CurrentPostingFromDt") + .HasColumnType("datetime") + .HasColumnName("CURRENT_POSTING_FROM_DT"); + + b.Property("DateOfBirth") + .HasColumnType("datetime") + .HasColumnName("DATE_OF_BIRTH"); + + b.Property("DateOfDeath") + .HasColumnType("datetime") + .HasColumnName("DATE_OF_DEATH"); + + b.Property("EmailAddressHome") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("EMAIL_ADDRESS_HOME"); + + b.Property("EmailAddressHomeFromDt") + .HasColumnType("datetime") + .HasColumnName("EMAIL_ADDRESS_HOME_FROM_DT"); + + b.Property("FamilyName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("FAMILY_NAME"); + + b.Property("Gender") + .HasColumnType("smallint") + .HasColumnName("GENDER"); + + b.Property("GivenName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("GIVEN_NAME"); + + b.Property("InterpreterRequired") + .HasColumnType("smallint") + .HasColumnName("INTERPRETER_REQUIRED"); + + b.Property("IsExtracted") + .HasColumnType("smallint") + .HasColumnName("IS_EXTRACTED"); + + b.Property("NHSNumber") + .HasColumnType("bigint") + .HasColumnName("NHS_NUMBER"); + + b.Property("NamePrefix") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("NAME_PREFIX"); + + b.Property("OtherGivenName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("OTHER_GIVEN_NAME"); + + b.Property("ParticipantId") + .HasColumnType("bigint") + .HasColumnName("PARTICIPANT_ID"); + + b.Property("PostCode") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("POST_CODE"); + + b.Property("PreferredLanguage") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("PREFERRED_LANGUAGE"); + + b.Property("PreviousFamilyName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("PREVIOUS_FAMILY_NAME"); + + b.Property("PrimaryCareProvider") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("PRIMARY_CARE_PROVIDER"); + + b.Property("PrimaryCareProviderDate") + .HasColumnType("datetime") + .HasColumnName("PRIMARY_CARE_PROVIDER_FROM_DT"); + + b.Property("ReasonForRemoval") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("REASON_FOR_REMOVAL"); + + b.Property("ReasonForRemovalDate") + .HasColumnType("datetime") + .HasColumnName("REASON_FOR_REMOVAL_FROM_DT"); + + b.Property("RecordInsertDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_INSERT_DATETIME"); + + b.Property("RecordUpdateDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATE_DATETIME"); + + b.Property("RequestId") + .HasColumnType("uniqueidentifier") + .HasColumnName("REQUEST_ID"); + + b.Property("SupersededNHSNumber") + .HasColumnType("bigint") + .HasColumnName("SUPERSEDED_NHS_NUMBER"); + + b.Property("TelephoneNumberHome") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("TELEPHONE_NUMBER_HOME"); + + b.Property("TelephoneNumberHomeFromDt") + .HasColumnType("datetime") + .HasColumnName("TELEPHONE_NUMBER_HOME_FROM_DT"); + + b.Property("TelephoneNumberMob") + .HasMaxLength(35) + .HasColumnType("nvarchar(35)") + .HasColumnName("TELEPHONE_NUMBER_MOB"); + + b.Property("TelephoneNumberMobFromDt") + .HasColumnType("datetime") + .HasColumnName("TELEPHONE_NUMBER_MOB_FROM_DT"); + + b.Property("UsualAddressFromDt") + .HasColumnType("datetime") + .HasColumnName("USUAL_ADDRESS_FROM_DT"); + + b.HasKey("CohortDistributionId"); + + b.HasIndex(new[] { "IsExtracted", "RequestId" }, "IX_BSCOHORT_IS_EXTACTED_REQUESTID"); + + b.HasIndex(new[] { "NHSNumber" }, "IX_BS_COHORT_DISTRIBUTION_NHSNUMBER"); + + b.ToTable("BS_COHORT_DISTRIBUTION", "dbo"); + }); + + modelBuilder.Entity("Model.CurrentPosting", b => + { + b.Property("Posting") + .HasColumnType("nvarchar(450)") + .HasColumnName("POSTING"); + + b.Property("InUse") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("IN_USE"); + + b.Property("IncludedInCohort") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("INCLUDED_IN_COHORT"); + + b.Property("PostingCategory") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("POSTING_CATEGORY"); + + b.HasKey("Posting"); + + b.ToTable("CURRENT_POSTING_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.ExceptionManagement", b => + { + b.Property("ExceptionId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("EXCEPTION_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ExceptionId")); + + b.Property("Category") + .HasColumnType("int") + .HasColumnName("CATEGORY"); + + b.Property("CohortName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("COHORT_NAME"); + + b.Property("DateCreated") + .HasColumnType("datetime") + .HasColumnName("DATE_CREATED"); + + b.Property("DateResolved") + .HasColumnType("date") + .HasColumnName("DATE_RESOLVED"); + + b.Property("ErrorRecord") + .HasColumnType("nvarchar(max)") + .HasColumnName("ERROR_RECORD"); + + b.Property("ExceptionDate") + .HasColumnType("datetime") + .HasColumnName("EXCEPTION_DATE"); + + b.Property("FileName") + .HasMaxLength(250) + .HasColumnType("nvarchar(250)") + .HasColumnName("FILE_NAME"); + + b.Property("IsFatal") + .HasColumnType("smallint") + .HasColumnName("IS_FATAL"); + + b.Property("NhsNumber") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("NHS_NUMBER"); + + b.Property("RecordUpdatedDate") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATED_DATE"); + + b.Property("RuleDescription") + .HasColumnType("nvarchar(max)") + .HasColumnName("RULE_DESCRIPTION"); + + b.Property("RuleId") + .HasColumnType("int") + .HasColumnName("RULE_ID"); + + b.Property("ScreeningName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasColumnName("SCREENING_NAME"); + + b.Property("ServiceNowCreatedDate") + .HasColumnType("date") + .HasColumnName("SERVICENOW_CREATED_DATE"); + + b.Property("ServiceNowId") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)") + .HasColumnName("SERVICENOW_ID"); + + b.HasKey("ExceptionId"); + + b.HasIndex(new[] { "NhsNumber", "ScreeningName" }, "IX_EXCEPTIONMGMT_NHSNUM_SCREENINGNAME"); + + b.ToTable("EXCEPTION_MANAGEMENT", "dbo"); + }); + + modelBuilder.Entity("Model.ExcludedSMULookup", b => + { + b.Property("GpPracticeCode") + .HasColumnType("nvarchar(450)") + .HasColumnName("GP_PRACTICE_CODE"); + + b.HasKey("GpPracticeCode"); + + b.ToTable("EXCLUDED_SMU_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.GenderMaster", b => + { + b.Property("GenderCd") + .HasMaxLength(2) + .HasColumnType("nvarchar(2)") + .HasColumnName("GENDER_CD"); + + b.Property("GenderDesc") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("GENDER_DESC"); + + b.HasKey("GenderCd"); + + b.ToTable("GENDER_MASTER", "dbo"); + }); + + modelBuilder.Entity("Model.GeneCodeLkp", b => + { + b.Property("GeneCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("GENE_CODE_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GeneCodeId")); + + b.Property("GeneCode") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("GENE_CODE"); + + b.Property("GeneCodeDescription") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("GENE_CODE_DESCRIPTION"); + + b.HasKey("GeneCodeId"); + + b.ToTable("GENE_CODE_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.HigherRiskReferralReasonLkp", b => + { + b.Property("HigherRiskReferralReasonId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("HIGHER_RISK_REFERRAL_REASON_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("HigherRiskReferralReasonId")); + + b.Property("HigherRiskReferralReasonCode") + .HasColumnType("nvarchar(max)") + .HasColumnName("HIGHER_RISK_REFERRAL_REASON_CODE"); + + b.Property("HigherRiskReferralReasonCodeDescription") + .HasColumnType("nvarchar(max)") + .HasColumnName("HIGHER_RISK_REFERRAL_REASON_CODE_DESCRIPTION"); + + b.HasKey("HigherRiskReferralReasonId"); + + b.ToTable("HIGHER_RISK_REFERRAL_REASON_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.LanguageCode", b => + { + b.Property("LanguageCodeId") + .HasColumnType("nvarchar(450)") + .HasColumnName("LANGUAGE_CODE"); + + b.Property("LanguageDescription") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("LANGUAGE_DESCRIPTION"); + + b.HasKey("LanguageCodeId"); + + b.ToTable("LANGUAGE_CODES", "dbo"); + }); + + modelBuilder.Entity("Model.NemsSubscription", b => + { + b.Property("SubscriptionId") + .HasColumnType("nvarchar(450)") + .HasColumnName("SUBSCRIPTION_ID"); + + b.Property("NhsNumber") + .HasColumnType("bigint") + .HasColumnName("NHS_NUMBER"); + + b.Property("RecordInsertDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_INSERT_DATETIME"); + + b.Property("RecordUpdateDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATE_DATETIME"); + + b.Property("SubscriptionSource") + .HasColumnType("int") + .HasColumnName("SUBSCRIPTION_SOURCE"); + + b.HasKey("SubscriptionId"); + + b.ToTable("NEMS_SUBSCRIPTION", "dbo"); + }); + + modelBuilder.Entity("Model.ParticipantDemographic", b => + { + b.Property("ParticipantId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("PARTICIPANT_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ParticipantId")); + + b.Property("AddressLine1") + .HasColumnType("nvarchar(max)") + .HasColumnName("ADDRESS_LINE_1"); + + b.Property("AddressLine2") + .HasColumnType("nvarchar(max)") + .HasColumnName("ADDRESS_LINE_2"); + + b.Property("AddressLine3") + .HasColumnType("nvarchar(max)") + .HasColumnName("ADDRESS_LINE_3"); + + b.Property("AddressLine4") + .HasColumnType("nvarchar(max)") + .HasColumnName("ADDRESS_LINE_4"); + + b.Property("AddressLine5") + .HasColumnType("nvarchar(max)") + .HasColumnName("ADDRESS_LINE_5"); + + b.Property("CurrentPosting") + .HasColumnType("nvarchar(max)") + .HasColumnName("CURRENT_POSTING"); + + b.Property("CurrentPostingFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("CURRENT_POSTING_FROM_DT"); + + b.Property("DateOfBirth") + .HasColumnType("nvarchar(max)") + .HasColumnName("DATE_OF_BIRTH"); + + b.Property("DateOfDeath") + .HasColumnType("nvarchar(max)") + .HasColumnName("DATE_OF_DEATH"); + + b.Property("DeathStatus") + .HasColumnType("smallint") + .HasColumnName("DEATH_STATUS"); + + b.Property("EmailAddressHome") + .HasColumnType("nvarchar(max)") + .HasColumnName("EMAIL_ADDRESS_HOME"); + + b.Property("EmailAddressHomeFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("EMAIL_ADDRESS_HOME_FROM_DT"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)") + .HasColumnName("FAMILY_NAME"); + + b.Property("Gender") + .HasColumnType("smallint") + .HasColumnName("GENDER"); + + b.Property("GivenName") + .HasColumnType("nvarchar(max)") + .HasColumnName("GIVEN_NAME"); + + b.Property("InterpreterRequired") + .HasColumnType("smallint") + .HasColumnName("INTERPRETER_REQUIRED"); + + b.Property("InvalidFlag") + .HasColumnType("smallint") + .HasColumnName("INVALID_FLAG"); + + b.Property("NamePrefix") + .HasColumnType("nvarchar(max)") + .HasColumnName("NAME_PREFIX"); + + b.Property("NhsNumber") + .HasColumnType("bigint") + .HasColumnName("NHS_NUMBER"); + + b.Property("OtherGivenName") + .HasColumnType("nvarchar(max)") + .HasColumnName("OTHER_GIVEN_NAME"); + + b.Property("PafKey") + .HasColumnType("nvarchar(max)") + .HasColumnName("PAF_KEY"); + + b.Property("PostCode") + .HasColumnType("nvarchar(max)") + .HasColumnName("POST_CODE"); + + b.Property("PreferredLanguage") + .HasColumnType("nvarchar(max)") + .HasColumnName("PREFERRED_LANGUAGE"); + + b.Property("PreviousFamilyName") + .HasColumnType("nvarchar(max)") + .HasColumnName("PREVIOUS_FAMILY_NAME"); + + b.Property("PrimaryCareProvider") + .HasColumnType("nvarchar(max)") + .HasColumnName("PRIMARY_CARE_PROVIDER"); + + b.Property("PrimaryCareProviderFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("PRIMARY_CARE_PROVIDER_FROM_DT"); + + b.Property("RecordInsertDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_INSERT_DATETIME"); + + b.Property("RecordUpdateDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATE_DATETIME"); + + b.Property("SupersededByNhsNumber") + .HasColumnType("bigint") + .HasColumnName("SUPERSEDED_BY_NHS_NUMBER"); + + b.Property("TelephoneNumberHome") + .HasColumnType("nvarchar(max)") + .HasColumnName("TELEPHONE_NUMBER_HOME"); + + b.Property("TelephoneNumberHomeFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("TELEPHONE_NUMBER_HOME_FROM_DT"); + + b.Property("TelephoneNumberMob") + .HasColumnType("nvarchar(max)") + .HasColumnName("TELEPHONE_NUMBER_MOB"); + + b.Property("TelephoneNumberMobFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("TELEPHONE_NUMBER_MOB_FROM_DT"); + + b.Property("UsualAddressFromDate") + .HasColumnType("nvarchar(max)") + .HasColumnName("USUAL_ADDRESS_FROM_DT"); + + b.HasKey("ParticipantId"); + + b.HasIndex(new[] { "NhsNumber" }, "Index_PARTICIPANT_DEMOGRAPHIC_NhsNumber"); + + b.ToTable("PARTICIPANT_DEMOGRAPHIC", "dbo"); + }); + + modelBuilder.Entity("Model.ParticipantManagement", b => + { + b.Property("ParticipantId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("PARTICIPANT_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ParticipantId")); + + b.Property("BlockedFlag") + .HasColumnType("smallint") + .HasColumnName("BLOCKED_FLAG"); + + b.Property("BusinessRuleVersion") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("BUSINESS_RULE_VERSION"); + + b.Property("DateIrradiated") + .HasColumnType("datetime") + .HasColumnName("DATE_IRRADIATED"); + + b.Property("EligibilityFlag") + .HasColumnType("smallint") + .HasColumnName("ELIGIBILITY_FLAG"); + + b.Property("ExceptionFlag") + .HasColumnType("smallint") + .HasColumnName("EXCEPTION_FLAG"); + + b.Property("GeneCodeId") + .HasColumnType("int") + .HasColumnName("GENE_CODE_ID"); + + b.Property("HigherRiskNextTestDueDate") + .HasColumnType("datetime") + .HasColumnName("HIGHER_RISK_NEXT_TEST_DUE_DATE"); + + b.Property("HigherRiskReferralReasonId") + .HasColumnType("int") + .HasColumnName("HIGHER_RISK_REFERRAL_REASON_ID"); + + b.Property("IsHigherRisk") + .HasColumnType("smallint") + .HasColumnName("IS_HIGHER_RISK"); + + b.Property("IsHigherRiskActive") + .HasColumnType("smallint") + .HasColumnName("IS_HIGHER_RISK_ACTIVE"); + + b.Property("NHSNumber") + .HasColumnType("bigint") + .HasColumnName("NHS_NUMBER"); + + b.Property("NextTestDueDate") + .HasColumnType("datetime") + .HasColumnName("NEXT_TEST_DUE_DATE"); + + b.Property("NextTestDueDateCalcMethod") + .HasColumnType("nvarchar(max)") + .HasColumnName("NEXT_TEST_DUE_DATE_CALC_METHOD"); + + b.Property("ParticipantScreeningStatus") + .HasColumnType("nvarchar(max)") + .HasColumnName("PARTICIPANT_SCREENING_STATUS"); + + b.Property("ReasonForRemoval") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("REASON_FOR_REMOVAL"); + + b.Property("ReasonForRemovalDate") + .HasColumnType("datetime") + .HasColumnName("REASON_FOR_REMOVAL_FROM_DT"); + + b.Property("RecordInsertDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_INSERT_DATETIME"); + + b.Property("RecordType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("RECORD_TYPE"); + + b.Property("RecordUpdateDateTime") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATE_DATETIME"); + + b.Property("ReferralFlag") + .HasColumnType("smallint") + .HasColumnName("REFERRAL_FLAG"); + + b.Property("ScreeningCeasedReason") + .HasColumnType("nvarchar(max)") + .HasColumnName("SCREENING_CEASED_REASON"); + + b.Property("ScreeningId") + .HasColumnType("bigint") + .HasColumnName("SCREENING_ID"); + + b.Property("SrcSysProcessedDateTime") + .HasColumnType("datetime") + .HasColumnName("SRC_SYSTEM_PROCESSED_DATETIME"); + + b.HasKey("ParticipantId"); + + b.HasIndex(new[] { "NHSNumber", "ScreeningId" }, "ix_PARTICIPANT_MANAGEMENT_screening_nhs"); + + b.ToTable("PARTICIPANT_MANAGEMENT", "dbo"); + }); + + modelBuilder.Entity("Model.ScreeningLkp", b => + { + b.Property("ScreeningWorkflowId") + .HasColumnType("nvarchar(450)") + .HasColumnName("SCREENING_WORKFLOW_ID"); + + b.Property("ScreeningAcronym") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("SCREENING_ACRONYM"); + + b.Property("ScreeningId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("SCREENING_ID"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ScreeningId")); + + b.Property("ScreeningName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("SCREENING_NAME"); + + b.Property("ScreeningType") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("SCREENING_TYPE"); + + b.HasKey("ScreeningWorkflowId"); + + b.ToTable("SCREENING_LKP", "dbo"); + }); + + modelBuilder.Entity("Model.ServicenowCases", b => + { + b.Property("ServicenowId") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("SERVICENOW_ID"); + + b.Property("NhsNumber") + .HasColumnType("bigint") + .HasColumnName("NHS_NUMBER"); + + b.Property("RecordInsertDatetime") + .HasColumnType("datetime") + .HasColumnName("RECORD_INSERT_DATETIME"); + + b.Property("RecordUpdateDatetime") + .HasColumnType("datetime") + .HasColumnName("RECORD_UPDATE_DATETIME"); + + b.Property("Status") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)") + .HasColumnName("STATUS"); + + b.HasKey("ServicenowId"); + + b.ToTable("SERVICENOW_CASES", "dbo"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/application/CohortManager/src/Functions/Shared/DataServices.Migrations/20250829_add_subscription_source_to_nems.cs b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.cs similarity index 77% rename from application/CohortManager/src/Functions/Shared/DataServices.Migrations/20250829_add_subscription_source_to_nems.cs rename to application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.cs index 4ab43b7464..b183a6b19f 100644 --- a/application/CohortManager/src/Functions/Shared/DataServices.Migrations/20250829_add_subscription_source_to_nems.cs +++ b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/20250829180608_add_subscription_source_to_nems.cs @@ -1,10 +1,13 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; -namespace DataServices.Database.Migrations +#nullable disable + +namespace DataServices.Migrations.Migrations { + /// public partial class add_subscription_source_to_nems : Migration { + /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn( @@ -15,6 +18,7 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: true); } + /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn( @@ -24,4 +28,3 @@ protected override void Down(MigrationBuilder migrationBuilder) } } } - diff --git a/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/DataServicesContextModelSnapshot.cs b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/DataServicesContextModelSnapshot.cs index 62324b3eb3..61f7842767 100644 --- a/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/DataServicesContextModelSnapshot.cs +++ b/application/CohortManager/src/Functions/Shared/DataServices.Migrations/Migrations/DataServicesContextModelSnapshot.cs @@ -721,6 +721,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("datetime") .HasColumnName("RECORD_UPDATE_DATETIME"); + b.Property("SubscriptionSource") + .HasColumnType("int") + .HasColumnName("SUBSCRIPTION_SOURCE"); + b.HasKey("SubscriptionId"); b.ToTable("NEMS_SUBSCRIPTION", "dbo"); From b1eb776562b65e043ac61f19be1fc8e68194e486 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Mon, 1 Sep 2025 10:07:56 +0100 Subject: [PATCH 19/49] fix: removed CaasNemsSubscriptionAccessor --- .../CaasNemsSubscriptionAccessor.cs | 54 ------------------- .../ManageCaasSubscription/Program.cs | 6 --- 2 files changed, 60 deletions(-) delete mode 100644 application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/CaasNemsSubscriptionAccessor.cs diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/CaasNemsSubscriptionAccessor.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/CaasNemsSubscriptionAccessor.cs deleted file mode 100644 index ca050338ce..0000000000 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/CaasNemsSubscriptionAccessor.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace NHS.CohortManager.DemographicServices; - -using System.Linq.Expressions; -using DataServices.Core; -using Model; - -public class CaasNemsSubscriptionAccessor : IDataServiceAccessor -{ - private readonly IDataServiceAccessor _inner; - public CaasNemsSubscriptionAccessor(IDataServiceAccessor inner) - { - _inner = inner; - } - - public Task GetSingle(Expression> predicate) - => _inner.GetSingle(predicate); - - public Task> GetRange(Expression> predicates) - => _inner.GetRange(predicates); - - public Task Remove(Expression> predicate) - => _inner.Remove(predicate); - - public async Task InsertSingle(NemsSubscription entity) - { - if (entity.SubscriptionSource == null) - { - entity.SubscriptionSource = SubscriptionSource.MESH; - } - return await _inner.InsertSingle(entity); - } - - public async Task InsertMany(IEnumerable entities) - { - foreach (var e in entities) - { - if (e.SubscriptionSource == null) - { - e.SubscriptionSource = SubscriptionSource.MESH; - } - } - return await _inner.InsertMany(entities); - } - - public async Task Update(NemsSubscription entity, Expression> predicate) - { - if (entity.SubscriptionSource == null) - { - entity.SubscriptionSource = SubscriptionSource.MESH; - } - return await _inner.Update(entity, predicate); - } -} - diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs index 26f230fca8..7c1c3545ee 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs @@ -41,12 +41,6 @@ services.AddScoped(); } services.AddScoped(); - // Wrap NemsSubscription accessor to enforce SubscriptionSource = MESH when missing - services.AddScoped>(sp => - { - var inner = sp.GetRequiredService>(); - return new NHS.CohortManager.DemographicServices.CaasNemsSubscriptionAccessor(inner); - }); }) .AddDataServicesHandler() .AddHttpClient() From 5c4b631000569148e9cac7c8d15491b4740a16fd Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Mon, 1 Sep 2025 16:34:51 +0100 Subject: [PATCH 20/49] fix: renamed parquet column to nhs_number --- .../src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs index acd738d389..77c3199fa4 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs @@ -48,7 +48,7 @@ private static byte[] CreateParquetFile(long nhsNumber) { var columns = new Column[] { - new Column("NhsNumber"), + new Column("nhs_number"), }; long[] nhsNumberList = { nhsNumber }; From 785535402fc1fc107fcc026a31f94d376378fcfa Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Mon, 1 Sep 2025 17:52:25 +0100 Subject: [PATCH 21/49] fix: added further config for local docker testing --- application/CohortManager/compose.core.yaml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/application/CohortManager/compose.core.yaml b/application/CohortManager/compose.core.yaml index 07ffcc0b0d..477ef4d413 100644 --- a/application/CohortManager/compose.core.yaml +++ b/application/CohortManager/compose.core.yaml @@ -13,7 +13,7 @@ services: environment: - AzureWebJobsStorage=${AZURITE_CONNECTION_STRING} - caasfolder_STORAGE=${AZURITE_CONNECTION_STRING} - - MeshApiBaseUrl=https://localhost:8700/messageexchange + - MeshApiBaseUrl=http://mesh_sandbox/messageexchange - BSSMailBox=X26ABC1 - MeshPassword=${MESHPASSWORD} - MeshSharedKey=${MESHSHAREDKEY} @@ -32,7 +32,7 @@ services: environment: - AzureWebJobsStorage=${AZURITE_CONNECTION_STRING} - nemsmeshfolder_STORAGE=${AZURITE_CONNECTION_STRING} - - NemsMeshApiBaseUrl=https://localhost:8700/messageexchange + - NemsMeshApiBaseUrl=http://mesh_sandbox/messageexchange - NemsMeshMailBox=${NEMS_MESH_MAILBOX} - NemsMeshPassword=${NEMS_MESH_PASSWORD} - NemsMeshSharedKey=${NEMS_MESH_SHARED_KEY} @@ -309,9 +309,19 @@ services: environment: - ASPNETCORE_URLS=http://*:9084 - FUNCTIONS_WORKER_RUNTIME=dotnet-isolated + - AzureWebJobsStorage=${AZURITE_CONNECTION_STRING} - ExceptionFunctionURL=http://create-exception:7070/api/CreateException - ManageNemsSubscriptionDataServiceURL=http://manage-nems-subscription:9081/api/NemsSubscriptionDataService - ManageNemsSubscriptionBaseURL=http://manage-nems-subscription:9081 + # MESH configuration (required for startup) + - MeshApiBaseUrl=http://mesh_sandbox/messageexchange + - MeshCaasPassword=password + - MeshCaasSharedKey=TestKey + # Optional: provide key file if available; empty keeps it disabled locally + - MeshCaasKeyName= + - MeshCaasKeyPassword= + # Set to true to avoid real MESH calls during local dev + - IsStubbed=false - CaasToMailbox=${CAAS_SUBSCRIBE_TO_MAILBOX:-CAAS_TO} - CaasFromMailbox=${CAAS_SUBSCRIBE_FROM_MAILBOX:-CAAS_FROM} - DtOsDatabaseConnectionString=Server=db,1433;Database=${DB_NAME};User Id=SA;Password=${PASSWORD};TrustServerCertificate=True From c0d293b87fc9f6605f6a29390216d771b07823e5 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Mon, 1 Sep 2025 17:53:05 +0100 Subject: [PATCH 22/49] fix: removed pointless comments --- application/CohortManager/compose.core.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/application/CohortManager/compose.core.yaml b/application/CohortManager/compose.core.yaml index 477ef4d413..d4bb9a5702 100644 --- a/application/CohortManager/compose.core.yaml +++ b/application/CohortManager/compose.core.yaml @@ -313,14 +313,11 @@ services: - ExceptionFunctionURL=http://create-exception:7070/api/CreateException - ManageNemsSubscriptionDataServiceURL=http://manage-nems-subscription:9081/api/NemsSubscriptionDataService - ManageNemsSubscriptionBaseURL=http://manage-nems-subscription:9081 - # MESH configuration (required for startup) - MeshApiBaseUrl=http://mesh_sandbox/messageexchange - MeshCaasPassword=password - MeshCaasSharedKey=TestKey - # Optional: provide key file if available; empty keeps it disabled locally - MeshCaasKeyName= - MeshCaasKeyPassword= - # Set to true to avoid real MESH calls during local dev - IsStubbed=false - CaasToMailbox=${CAAS_SUBSCRIBE_TO_MAILBOX:-CAAS_TO} - CaasFromMailbox=${CAAS_SUBSCRIBE_FROM_MAILBOX:-CAAS_FROM} From 1178ad377601b60e27372ca7bcd89f966c9b4bcd Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Mon, 1 Sep 2025 17:54:29 +0100 Subject: [PATCH 23/49] fix: undone changes to other functions in compose yml --- application/CohortManager/compose.core.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/CohortManager/compose.core.yaml b/application/CohortManager/compose.core.yaml index d4bb9a5702..9f16feffc0 100644 --- a/application/CohortManager/compose.core.yaml +++ b/application/CohortManager/compose.core.yaml @@ -13,7 +13,7 @@ services: environment: - AzureWebJobsStorage=${AZURITE_CONNECTION_STRING} - caasfolder_STORAGE=${AZURITE_CONNECTION_STRING} - - MeshApiBaseUrl=http://mesh_sandbox/messageexchange + - MeshApiBaseUrl=https://localhost:8700/messageexchange - BSSMailBox=X26ABC1 - MeshPassword=${MESHPASSWORD} - MeshSharedKey=${MESHSHAREDKEY} @@ -313,7 +313,7 @@ services: - ExceptionFunctionURL=http://create-exception:7070/api/CreateException - ManageNemsSubscriptionDataServiceURL=http://manage-nems-subscription:9081/api/NemsSubscriptionDataService - ManageNemsSubscriptionBaseURL=http://manage-nems-subscription:9081 - - MeshApiBaseUrl=http://mesh_sandbox/messageexchange + - MeshApiBaseUrl=https://localhost:8700/messageexchange - MeshCaasPassword=password - MeshCaasSharedKey=TestKey - MeshCaasKeyName= From c637d84d44efdb3db0d9356f6dd50cd27b735563 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Mon, 1 Sep 2025 17:55:47 +0100 Subject: [PATCH 24/49] fix: reverted change to nems-mesh-retrieval compose --- application/CohortManager/compose.core.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/CohortManager/compose.core.yaml b/application/CohortManager/compose.core.yaml index 9f16feffc0..507d5bf4a4 100644 --- a/application/CohortManager/compose.core.yaml +++ b/application/CohortManager/compose.core.yaml @@ -32,7 +32,7 @@ services: environment: - AzureWebJobsStorage=${AZURITE_CONNECTION_STRING} - nemsmeshfolder_STORAGE=${AZURITE_CONNECTION_STRING} - - NemsMeshApiBaseUrl=http://mesh_sandbox/messageexchange + - NemsMeshApiBaseUrl=https://localhost:8700/messageexchange - NemsMeshMailBox=${NEMS_MESH_MAILBOX} - NemsMeshPassword=${NEMS_MESH_PASSWORD} - NemsMeshSharedKey=${NEMS_MESH_SHARED_KEY} From d7f7dfb04b371dbe5246bbbe91b2ba070fa8799f Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Mon, 1 Sep 2025 18:20:56 +0100 Subject: [PATCH 25/49] fix: clean up ambiguity for certificate helper and fix unit test --- .../NemsMeshRetrieval/CertificateHelper.cs | 31 ------------------- .../NemsMeshRetrieval/Program.cs | 4 +-- .../ManageCaasSubscriptionTests.cs | 19 ++++++++++-- 3 files changed, 17 insertions(+), 37 deletions(-) delete mode 100644 application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/CertificateHelper.cs diff --git a/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/CertificateHelper.cs b/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/CertificateHelper.cs deleted file mode 100644 index b44048dcb3..0000000000 --- a/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/CertificateHelper.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace NHS.CohortManager.CaasIntegrationService; - -using System.Security.Cryptography.X509Certificates; - -public static class CertificateHelper -{ - public static X509Certificate2Collection GetCertificatesFromString(string certificatesString) - { - X509Certificate2Collection certs = []; - - X509Certificate2[] pemCerts = certificatesString - .Split("-----END CERTIFICATE-----", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(pem => pem + "\n-----END CERTIFICATE-----") - .Select(pem => - { - var base64 = pem - .Replace("-----BEGIN CERTIFICATE-----", "") - .Replace("-----END CERTIFICATE-----", "") - .Replace("\n", "") - .Replace("\r", "") - .Trim(); - - return new X509Certificate2(Convert.FromBase64String(base64)); - }) - .ToArray(); - - certs.AddRange(pemCerts); - - return certs; - } -} \ No newline at end of file diff --git a/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/Program.cs b/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/Program.cs index 75eee8eecc..6573e70f7d 100644 --- a/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/Program.cs +++ b/application/CohortManager/src/Functions/NemsSubscriptionService/NemsMeshRetrieval/Program.cs @@ -10,7 +10,6 @@ using NHS.Screening.NemsMeshRetrieval; using HealthChecks.Extensions; using Azure.Security.KeyVault.Secrets; -using NHS.CohortManager.CaasIntegrationService; var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); @@ -20,7 +19,7 @@ { var host = new HostBuilder(); - X509Certificate2 cohortManagerPrivateKey = null; + X509Certificate2 cohortManagerPrivateKey = null!; X509Certificate2Collection meshCerts = []; host.AddConfiguration(out NemsMeshRetrievalConfig config); @@ -83,4 +82,3 @@ logger.LogCritical(ex, "Failed to start up Function"); } - diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index 30af90f4bf..d71439a7ce 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -35,7 +35,9 @@ public ManageCaasSubscriptionTests() { ManageNemsSubscriptionDataServiceURL = null, // keep stub mode during unit tests CaasToMailbox = "TEST_TO", - CaasFromMailbox = "TEST_FROM" + CaasFromMailbox = "TEST_FROM", + MeshApiBaseUrl = "http://localhost", + MeshCaasSharedKey = "dummy" }); _mesh @@ -52,6 +54,11 @@ public ManageCaasSubscriptionTests() .Setup(r => r.HandleRequest(It.IsAny(), It.IsAny())) .ReturnsAsync((HttpRequestData r, string k) => _createResponse.CreateHttpResponse(HttpStatusCode.OK, r, "OK")); + // Default: DB insert succeeds for subscribe happy path + _nemsAccessor + .Setup(a => a.InsertSingle(It.IsAny())) + .ReturnsAsync(true); + _sut = new ManageCaasSubscription( _logger.Object, _createResponse, @@ -229,7 +236,9 @@ public async Task PollMeshMailbox_UsesConfigFromMailbox() _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig { CaasFromMailbox = "TEST_FROM", - CaasToMailbox = "TEST_TO" + CaasToMailbox = "TEST_TO", + MeshApiBaseUrl = "http://localhost", + MeshCaasSharedKey = "dummy" }); var sut = new ManageCaasSubscription( @@ -249,7 +258,11 @@ public async Task PollMeshMailbox_UsesConfigFromMailbox() [TestMethod] public void Config_MissingMailboxes_FailsValidation() { - var cfg = new ManageCaasSubscriptionConfig(); + var cfg = new ManageCaasSubscriptionConfig + { + MeshApiBaseUrl = "http://localhost", + MeshCaasSharedKey = "dummy" + }; var context = new ValidationContext(cfg); var results = new System.Collections.Generic.List(); var isValid = Validator.TryValidateObject(cfg, context, results, validateAllProperties: true); From 105424d96bc0f4bf28a4541c4fe542f685e3ed34 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 10:57:42 +0100 Subject: [PATCH 26/49] fix: resolved PR comments - required attribute, removal of comments, debug outputs --- application/CohortManager/compose.core.yaml | 1 - .../ManageCaasSubscription/ManageCaasSubscriptionConfig.cs | 6 ++---- .../Shared/Common/Extensions/MeshMailboxExtension.cs | 2 +- .../Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs | 4 +--- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/application/CohortManager/compose.core.yaml b/application/CohortManager/compose.core.yaml index 507d5bf4a4..a34bb739d0 100644 --- a/application/CohortManager/compose.core.yaml +++ b/application/CohortManager/compose.core.yaml @@ -312,7 +312,6 @@ services: - AzureWebJobsStorage=${AZURITE_CONNECTION_STRING} - ExceptionFunctionURL=http://create-exception:7070/api/CreateException - ManageNemsSubscriptionDataServiceURL=http://manage-nems-subscription:9081/api/NemsSubscriptionDataService - - ManageNemsSubscriptionBaseURL=http://manage-nems-subscription:9081 - MeshApiBaseUrl=https://localhost:8700/messageexchange - MeshCaasPassword=password - MeshCaasSharedKey=TestKey diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs index 4a9c640448..ea298ce665 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs @@ -9,10 +9,8 @@ public class ManageCaasSubscriptionConfig // Example: http://manage-nems-subscription:9081/api/NemsSubscriptionDataService public string? ManageNemsSubscriptionDataServiceURL { get; set; } - // Optional base URL to forward selected endpoints (e.g., CheckSubscriptionStatus) - // Example: http://manage-nems-subscription:9081 - public string? ManageNemsSubscriptionBaseURL { get; set; } - public required string MeshApiBaseUrl { get; set; } + [Required] + public string? MeshApiBaseUrl { get; set; } public string? KeyVaultConnectionString { get; set; } public bool BypassServerCertificateValidation { get; set; } = false; public string? MeshCACertName { get; set; } diff --git a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs index ca20223e7c..e578aa5a6d 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs @@ -16,7 +16,7 @@ public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshC { ILoggerFactory factory = LoggerFactory.Create(builder => { - builder.AddConsole(); // or AddDebug(), AddEventSourceLogger(), etc. + builder.AddConsole(); builder.AddApplicationInsights(); }); _logger = factory.CreateLogger("MeshMailboxExtension"); diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs index 77c3199fa4..6d80de63a0 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs @@ -31,9 +31,7 @@ public async Task SendSubscriptionRequest(long nhsNumber, string toMailb Content = content, ContentType = "application/octet-stream" }; - - await File.WriteAllBytesAsync("Testpremesh.parquet", content); - + var result = await _meshOutboxService.SendCompressedMessageAsync(fromMailbox, toMailbox, _config.SendCaasWorkflowId, file); if (!result.IsSuccessful) { From 4f8bd7f02895586a7d568c5ea968d5b6c5794be5 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 11:33:52 +0100 Subject: [PATCH 27/49] fix: removed ManageNemsSubscriptionDataServiceURL from ManageCaasSubscription config --- application/CohortManager/compose.core.yaml | 1 - .../ManageCaasSubscription/ManageCaasSubscriptionConfig.cs | 4 ---- .../ManageCaasSubscriptionTests.cs | 1 - 3 files changed, 6 deletions(-) diff --git a/application/CohortManager/compose.core.yaml b/application/CohortManager/compose.core.yaml index a34bb739d0..4bf66be83e 100644 --- a/application/CohortManager/compose.core.yaml +++ b/application/CohortManager/compose.core.yaml @@ -311,7 +311,6 @@ services: - FUNCTIONS_WORKER_RUNTIME=dotnet-isolated - AzureWebJobsStorage=${AZURITE_CONNECTION_STRING} - ExceptionFunctionURL=http://create-exception:7070/api/CreateException - - ManageNemsSubscriptionDataServiceURL=http://manage-nems-subscription:9081/api/NemsSubscriptionDataService - MeshApiBaseUrl=https://localhost:8700/messageexchange - MeshCaasPassword=password - MeshCaasSharedKey=TestKey diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs index ea298ce665..9a11e88cf1 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs @@ -5,10 +5,6 @@ namespace NHS.CohortManager.DemographicServices; // Minimal config object for ManageCaasSubscription. public class ManageCaasSubscriptionConfig { - // Optional URL for pass-through to the existing NEMS data service - // Example: http://manage-nems-subscription:9081/api/NemsSubscriptionDataService - public string? ManageNemsSubscriptionDataServiceURL { get; set; } - [Required] public string? MeshApiBaseUrl { get; set; } public string? KeyVaultConnectionString { get; set; } diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index d71439a7ce..c478efa87d 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -33,7 +33,6 @@ public ManageCaasSubscriptionTests() { _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig { - ManageNemsSubscriptionDataServiceURL = null, // keep stub mode during unit tests CaasToMailbox = "TEST_TO", CaasFromMailbox = "TEST_FROM", MeshApiBaseUrl = "http://localhost", From 736a1d6d904e7d45615fd053f569f7df854431b6 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 12:00:38 +0100 Subject: [PATCH 28/49] test: added further basic unit tests around the MeshPoller and shared Caas subscribe code --- .../ManageCaasSubscriptionTests.cs | 18 +++++ .../ManageCaasSubscriptionTests.csproj | 2 +- .../MeshMailboxExtensionTests.cs | 39 +++++++++ .../MeshPollerTests.cs | 46 +++++++++++ .../MeshSendCaasSubscribeTests.cs | 79 +++++++++++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs create mode 100644 tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs create mode 100644 tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index c478efa87d..ae890ee251 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -269,4 +269,22 @@ public void Config_MissingMailboxes_FailsValidation() Assert.IsTrue(results.Any(r => r.MemberNames.Contains("CaasToMailbox"))); Assert.IsTrue(results.Any(r => r.MemberNames.Contains("CaasFromMailbox"))); } + + [TestMethod] + public void Config_MissingMeshApiBaseUrl_FailsValidation() + { + var cfg = new ManageCaasSubscriptionConfig + { + CaasToMailbox = "TEST_TO", + CaasFromMailbox = "TEST_FROM", + MeshCaasSharedKey = "dummy" + }; + + var context = new ValidationContext(cfg); + var results = new System.Collections.Generic.List(); + var isValid = Validator.TryValidateObject(cfg, context, results, validateAllProperties: true); + + Assert.IsFalse(isValid); + Assert.IsTrue(results.Any(r => r.MemberNames.Contains("MeshApiBaseUrl"))); + } } diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj index 8743dc1cc0..7b53f23ae7 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.csproj @@ -21,6 +21,6 @@ + - diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs new file mode 100644 index 0000000000..c6912fc82b --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs @@ -0,0 +1,39 @@ +namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; + +using System.Security.Cryptography.X509Certificates; +using System.IO; +using System; +using Common; + +[TestClass] +public class MeshMailboxExtensionTests +{ + [TestMethod] + public async Task GetCACertificates_FromFilePath_ReturnsCollection() + { + var path = FindInParents("nems_certificate.pem"); + Assert.IsTrue(File.Exists(path), $"Test certificate not found at {path}"); + var certs = await MeshMailboxExtension.GetCACertificates(path, null); + Assert.IsNotNull(certs); + Assert.IsInstanceOfType(certs, typeof(X509Certificate2Collection)); + Assert.IsTrue(certs!.Count > 0); + } + + [TestMethod] + public async Task GetCACertificates_NoInputs_ReturnsNull() + { + var certs = await MeshMailboxExtension.GetCACertificates(null, null); + Assert.IsNull(certs); + } + private static string FindInParents(string fileName) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, fileName); + if (File.Exists(candidate)) return candidate; + dir = dir.Parent; + } + return fileName; // will fail later if not found + } +} diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs new file mode 100644 index 0000000000..ded3b72e1c --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs @@ -0,0 +1,46 @@ +namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; + +using System.Threading.Tasks; +using Common; +using Microsoft.Extensions.Logging; +using Moq; +using NHS.MESH.Client.Contracts.Services; +using NHS.MESH.Client.Models; + +[TestClass] +public class MeshPollerTests +{ + private readonly Mock> _logger = new(); + private readonly Mock _meshOps = new(); + + [TestMethod] + public async Task ExecuteHandshake_Success_ReturnsTrue() + { + _meshOps + .Setup(m => m.MeshHandshakeAsync(It.IsAny())) + .ReturnsAsync(new MeshResponse { IsSuccessful = true, Response = new HandshakeResponse { MailboxId = "MAILBOX" } }); + + var sut = new MeshPoller(_logger.Object, _meshOps.Object); + var result = await sut.ExecuteHandshake("MAILBOX"); + Assert.IsTrue(result); + } + + [TestMethod] + public async Task ExecuteHandshake_Failure_ReturnsFalse() + { + _meshOps + .Setup(m => m.MeshHandshakeAsync(It.IsAny())) + .ReturnsAsync(new MeshResponse { IsSuccessful = false, Error = new APIErrorResponse { ErrorCode = "500", ErrorDescription = "err" } }); + + var sut = new MeshPoller(_logger.Object, _meshOps.Object); + var result = await sut.ExecuteHandshake("MAILBOX"); + Assert.IsFalse(result); + } + + [TestMethod] + public async Task ShouldExecuteHandshake_NotImplemented_Throws() + { + var sut = new MeshPoller(_logger.Object, _meshOps.Object); + await Assert.ThrowsExceptionAsync(() => sut.ShouldExecuteHandshake("MAILBOX", "config.json")); + } +} diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs new file mode 100644 index 0000000000..53912cd47f --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs @@ -0,0 +1,79 @@ +namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; + +using System.Threading.Tasks; +using Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NHS.MESH.Client.Contracts.Services; +using NHS.MESH.Client.Models; + +[TestClass] +public class MeshSendCaasSubscribeTests +{ + private readonly Mock> _logger = new(); + private readonly Mock _meshOutbox = new(); + + private MeshSendCaasSubscribe CreateSut(string workflowId = "WF-CAAS-SUB") + { + var cfg = Options.Create(new MeshSendCaasSubscribeConfig { SendCaasWorkflowId = workflowId }); + return new MeshSendCaasSubscribe(_logger.Object, _meshOutbox.Object, cfg); + } + + [TestMethod] + public async Task SendSubscriptionRequest_Success_SendsExpectedAttachment_AndReturnsMessageId() + { + // Arrange + string? capturedFrom = null, capturedTo = null, capturedWorkflow = null; + FileAttachment? capturedFile = null; + _meshOutbox + .Setup(m => m.SendCompressedMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null, null, false)) + .Callback((string from, string to, string workflow, FileAttachment file, string? _, string? __, bool ___) => + { + capturedFrom = from; capturedTo = to; capturedWorkflow = workflow; capturedFile = file; + }) + .ReturnsAsync(new MeshResponse + { + IsSuccessful = true, + Response = new SendMessageResponse { MessageId = "MSG123" } + }); + + var sut = CreateSut(); + + // Act + var result = await sut.SendSubscriptionRequest(9000000009L, "TO_BOX", "FROM_BOX"); + + // Assert + Assert.AreEqual("MSG123", result); + Assert.AreEqual("FROM_BOX", capturedFrom); + Assert.AreEqual("TO_BOX", capturedTo); + Assert.AreEqual("WF-CAAS-SUB", capturedWorkflow); + Assert.IsNotNull(capturedFile); + Assert.AreEqual("CaaSSubscribe.parquet", capturedFile!.FileName); + Assert.AreEqual("application/octet-stream", capturedFile.ContentType); + Assert.IsNotNull(capturedFile.Content); + Assert.IsTrue(capturedFile.Content.Length > 0); + } + + [TestMethod] + public async Task SendSubscriptionRequest_Failure_ReturnsNull() + { + // Arrange + _meshOutbox + .Setup(m => m.SendCompressedMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null, null, false)) + .ReturnsAsync(new MeshResponse + { + IsSuccessful = false, + Error = new APIErrorResponse { ErrorCode = "500", ErrorDescription = "boom" } + }); + + var sut = CreateSut(); + + // Act + var result = await sut.SendSubscriptionRequest(9000000009L, "TO_BOX", "FROM_BOX"); + + // Assert + Assert.IsNull(result); + } +} + From eb262907be583100e0772cb1cbe249b9fb7b4f74 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 12:35:45 +0100 Subject: [PATCH 29/49] fix: removed test that could not complete in pipeline --- .../MeshSendCaasSubscribeTests.cs | 79 ------------------- 1 file changed, 79 deletions(-) delete mode 100644 tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs deleted file mode 100644 index 53912cd47f..0000000000 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs +++ /dev/null @@ -1,79 +0,0 @@ -namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; - -using System.Threading.Tasks; -using Common; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Moq; -using NHS.MESH.Client.Contracts.Services; -using NHS.MESH.Client.Models; - -[TestClass] -public class MeshSendCaasSubscribeTests -{ - private readonly Mock> _logger = new(); - private readonly Mock _meshOutbox = new(); - - private MeshSendCaasSubscribe CreateSut(string workflowId = "WF-CAAS-SUB") - { - var cfg = Options.Create(new MeshSendCaasSubscribeConfig { SendCaasWorkflowId = workflowId }); - return new MeshSendCaasSubscribe(_logger.Object, _meshOutbox.Object, cfg); - } - - [TestMethod] - public async Task SendSubscriptionRequest_Success_SendsExpectedAttachment_AndReturnsMessageId() - { - // Arrange - string? capturedFrom = null, capturedTo = null, capturedWorkflow = null; - FileAttachment? capturedFile = null; - _meshOutbox - .Setup(m => m.SendCompressedMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null, null, false)) - .Callback((string from, string to, string workflow, FileAttachment file, string? _, string? __, bool ___) => - { - capturedFrom = from; capturedTo = to; capturedWorkflow = workflow; capturedFile = file; - }) - .ReturnsAsync(new MeshResponse - { - IsSuccessful = true, - Response = new SendMessageResponse { MessageId = "MSG123" } - }); - - var sut = CreateSut(); - - // Act - var result = await sut.SendSubscriptionRequest(9000000009L, "TO_BOX", "FROM_BOX"); - - // Assert - Assert.AreEqual("MSG123", result); - Assert.AreEqual("FROM_BOX", capturedFrom); - Assert.AreEqual("TO_BOX", capturedTo); - Assert.AreEqual("WF-CAAS-SUB", capturedWorkflow); - Assert.IsNotNull(capturedFile); - Assert.AreEqual("CaaSSubscribe.parquet", capturedFile!.FileName); - Assert.AreEqual("application/octet-stream", capturedFile.ContentType); - Assert.IsNotNull(capturedFile.Content); - Assert.IsTrue(capturedFile.Content.Length > 0); - } - - [TestMethod] - public async Task SendSubscriptionRequest_Failure_ReturnsNull() - { - // Arrange - _meshOutbox - .Setup(m => m.SendCompressedMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null, null, false)) - .ReturnsAsync(new MeshResponse - { - IsSuccessful = false, - Error = new APIErrorResponse { ErrorCode = "500", ErrorDescription = "boom" } - }); - - var sut = CreateSut(); - - // Act - var result = await sut.SendSubscriptionRequest(9000000009L, "TO_BOX", "FROM_BOX"); - - // Assert - Assert.IsNull(result); - } -} - From fa3deba44189b0cc347da7fe046a617259cffb16 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 12:58:07 +0100 Subject: [PATCH 30/49] fix: attempt to fix trx upload upon test failure to diagnose which test is failing --- .github/workflows/stage-2-test.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 86fefd4c3e..97ddc15d09 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -137,12 +137,14 @@ jobs: --collect:"XPlat Code Coverage;Format=opencover;Include=**/*.cs;ExcludeByFile=**/*Tests.cs,**/Tests/**/*.cs,**/Program.cs,**/Model/**/*.cs,**/Set-up/**/*.cs,**/scripts/**/*.cs,**/HealthCheckFunction.cs,**/*Config.cs,**/bin/**/*.cs,**/obj/**/*.cs,**/Properties/**/*.cs,**/*.generated.cs,**/*.Designer.cs,**/*.g.cs,**/*.GlobalUsings.g.cs,**/*.AssemblyInfo.cs" \ --verbosity quiet - name: Upload test results as artifact + if: always() uses: actions/upload-artifact@v4 with: name: test-results-consolidated path: | TestResults/**/*.${{ inputs.unit_test_logger_format }} TestResults/**/coverage.opencover.xml + if-no-files-found: ignore test-unit: name: Unit tests @@ -189,12 +191,14 @@ jobs: --verbosity quiet done - name: Upload test results as artifact + if: always() uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.scope }} path: | TestResults/**/*.${{ inputs.unit_test_logger_format }} TestResults/**/coverage.opencover.xml + if-no-files-found: ignore aggregate-test-results: name: Aggregate results and report @@ -261,4 +265,4 @@ jobs: make test-lint - name: Save the linting result run: | - echo "Nothing to save" \ No newline at end of file + echo "Nothing to save" From 6e18c538a45ad716678e8b4d501f256c3f203330 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 13:26:11 +0100 Subject: [PATCH 31/49] fix: readded test, corrected pem mock --- .../MeshMailboxExtensionTests.cs | 42 ++++++---- .../MeshSendCaasSubscribeTests.cs | 79 +++++++++++++++++++ 2 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs index c6912fc82b..64f57100ca 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs @@ -11,12 +11,29 @@ public class MeshMailboxExtensionTests [TestMethod] public async Task GetCACertificates_FromFilePath_ReturnsCollection() { - var path = FindInParents("nems_certificate.pem"); - Assert.IsTrue(File.Exists(path), $"Test certificate not found at {path}"); - var certs = await MeshMailboxExtension.GetCACertificates(path, null); - Assert.IsNotNull(certs); - Assert.IsInstanceOfType(certs, typeof(X509Certificate2Collection)); - Assert.IsTrue(certs!.Count > 0); + // Create a temporary self-signed certificate and write as PEM to a temp file + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=Test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var der = cert.Export(X509ContentType.Cert); + var pem = "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + + var tempPath = Path.Combine(Path.GetTempPath(), $"test-cert-{Guid.NewGuid():N}.pem"); + await File.WriteAllTextAsync(tempPath, pem); + + try + { + var certs = await MeshMailboxExtension.GetCACertificates(tempPath, null); + Assert.IsNotNull(certs); + Assert.IsInstanceOfType(certs, typeof(X509Certificate2Collection)); + Assert.IsTrue(certs!.Count > 0); + } + finally + { + if (File.Exists(tempPath)) File.Delete(tempPath); + } } [TestMethod] @@ -25,15 +42,6 @@ public async Task GetCACertificates_NoInputs_ReturnsNull() var certs = await MeshMailboxExtension.GetCACertificates(null, null); Assert.IsNull(certs); } - private static string FindInParents(string fileName) - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir != null) - { - var candidate = Path.Combine(dir.FullName, fileName); - if (File.Exists(candidate)) return candidate; - dir = dir.Parent; - } - return fileName; // will fail later if not found - } + // kept for potential future use; not used after temp-cert approach + private static string FindInParents(string fileName) => fileName; } diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs new file mode 100644 index 0000000000..53912cd47f --- /dev/null +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshSendCaasSubscribeTests.cs @@ -0,0 +1,79 @@ +namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; + +using System.Threading.Tasks; +using Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NHS.MESH.Client.Contracts.Services; +using NHS.MESH.Client.Models; + +[TestClass] +public class MeshSendCaasSubscribeTests +{ + private readonly Mock> _logger = new(); + private readonly Mock _meshOutbox = new(); + + private MeshSendCaasSubscribe CreateSut(string workflowId = "WF-CAAS-SUB") + { + var cfg = Options.Create(new MeshSendCaasSubscribeConfig { SendCaasWorkflowId = workflowId }); + return new MeshSendCaasSubscribe(_logger.Object, _meshOutbox.Object, cfg); + } + + [TestMethod] + public async Task SendSubscriptionRequest_Success_SendsExpectedAttachment_AndReturnsMessageId() + { + // Arrange + string? capturedFrom = null, capturedTo = null, capturedWorkflow = null; + FileAttachment? capturedFile = null; + _meshOutbox + .Setup(m => m.SendCompressedMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null, null, false)) + .Callback((string from, string to, string workflow, FileAttachment file, string? _, string? __, bool ___) => + { + capturedFrom = from; capturedTo = to; capturedWorkflow = workflow; capturedFile = file; + }) + .ReturnsAsync(new MeshResponse + { + IsSuccessful = true, + Response = new SendMessageResponse { MessageId = "MSG123" } + }); + + var sut = CreateSut(); + + // Act + var result = await sut.SendSubscriptionRequest(9000000009L, "TO_BOX", "FROM_BOX"); + + // Assert + Assert.AreEqual("MSG123", result); + Assert.AreEqual("FROM_BOX", capturedFrom); + Assert.AreEqual("TO_BOX", capturedTo); + Assert.AreEqual("WF-CAAS-SUB", capturedWorkflow); + Assert.IsNotNull(capturedFile); + Assert.AreEqual("CaaSSubscribe.parquet", capturedFile!.FileName); + Assert.AreEqual("application/octet-stream", capturedFile.ContentType); + Assert.IsNotNull(capturedFile.Content); + Assert.IsTrue(capturedFile.Content.Length > 0); + } + + [TestMethod] + public async Task SendSubscriptionRequest_Failure_ReturnsNull() + { + // Arrange + _meshOutbox + .Setup(m => m.SendCompressedMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null, null, false)) + .ReturnsAsync(new MeshResponse + { + IsSuccessful = false, + Error = new APIErrorResponse { ErrorCode = "500", ErrorDescription = "boom" } + }); + + var sut = CreateSut(); + + // Act + var result = await sut.SendSubscriptionRequest(9000000009L, "TO_BOX", "FROM_BOX"); + + // Assert + Assert.IsNull(result); + } +} + From c575751bded1e9ae8e68a935921309236bd767b2 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 13:30:44 +0100 Subject: [PATCH 32/49] fix: missing using statement, correcting test setup --- .../ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs index 64f57100ca..5a0722cad3 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs @@ -1,6 +1,7 @@ namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography; using System.IO; using System; using Common; @@ -13,7 +14,8 @@ public async Task GetCACertificates_FromFilePath_ReturnsCollection() { // Create a temporary self-signed certificate and write as PEM to a temp file using var rsa = RSA.Create(2048); - var req = new CertificateRequest("CN=Test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var subject = new X500DistinguishedName("CN=Test"); + var req = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); var der = cert.Export(X509ContentType.Cert); var pem = "-----BEGIN CERTIFICATE-----\n" From 682668f9e53595df225d7d42b72e23f18615ab8e Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 13:33:16 +0100 Subject: [PATCH 33/49] fix: added AAA comments --- .../ManageCaasSubscriptionTests.cs | 4 ++++ .../MeshMailboxExtensionTests.cs | 10 +++++++++- .../ManageCaasSubscriptionTests/MeshPollerTests.cs | 13 +++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index ae890ee251..06113ccd28 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -273,6 +273,7 @@ public void Config_MissingMailboxes_FailsValidation() [TestMethod] public void Config_MissingMeshApiBaseUrl_FailsValidation() { + // Arrange var cfg = new ManageCaasSubscriptionConfig { CaasToMailbox = "TEST_TO", @@ -282,8 +283,11 @@ public void Config_MissingMeshApiBaseUrl_FailsValidation() var context = new ValidationContext(cfg); var results = new System.Collections.Generic.List(); + + // Act var isValid = Validator.TryValidateObject(cfg, context, results, validateAllProperties: true); + // Assert Assert.IsFalse(isValid); Assert.IsTrue(results.Any(r => r.MemberNames.Contains("MeshApiBaseUrl"))); } diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs index 5a0722cad3..71379b55a5 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs @@ -12,7 +12,7 @@ public class MeshMailboxExtensionTests [TestMethod] public async Task GetCACertificates_FromFilePath_ReturnsCollection() { - // Create a temporary self-signed certificate and write as PEM to a temp file + // Arrange: create a temporary self-signed certificate and write as PEM to a temp file using var rsa = RSA.Create(2048); var subject = new X500DistinguishedName("CN=Test"); var req = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); @@ -27,7 +27,10 @@ public async Task GetCACertificates_FromFilePath_ReturnsCollection() try { + // Act var certs = await MeshMailboxExtension.GetCACertificates(tempPath, null); + + // Assert Assert.IsNotNull(certs); Assert.IsInstanceOfType(certs, typeof(X509Certificate2Collection)); Assert.IsTrue(certs!.Count > 0); @@ -41,7 +44,12 @@ public async Task GetCACertificates_FromFilePath_ReturnsCollection() [TestMethod] public async Task GetCACertificates_NoInputs_ReturnsNull() { + // Arrange: no inputs + + // Act var certs = await MeshMailboxExtension.GetCACertificates(null, null); + + // Assert Assert.IsNull(certs); } // kept for potential future use; not used after temp-cert approach diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs index ded3b72e1c..4a4106b71f 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshPollerTests.cs @@ -16,31 +16,44 @@ public class MeshPollerTests [TestMethod] public async Task ExecuteHandshake_Success_ReturnsTrue() { + // Arrange _meshOps .Setup(m => m.MeshHandshakeAsync(It.IsAny())) .ReturnsAsync(new MeshResponse { IsSuccessful = true, Response = new HandshakeResponse { MailboxId = "MAILBOX" } }); var sut = new MeshPoller(_logger.Object, _meshOps.Object); + + // Act var result = await sut.ExecuteHandshake("MAILBOX"); + + // Assert Assert.IsTrue(result); } [TestMethod] public async Task ExecuteHandshake_Failure_ReturnsFalse() { + // Arrange _meshOps .Setup(m => m.MeshHandshakeAsync(It.IsAny())) .ReturnsAsync(new MeshResponse { IsSuccessful = false, Error = new APIErrorResponse { ErrorCode = "500", ErrorDescription = "err" } }); var sut = new MeshPoller(_logger.Object, _meshOps.Object); + + // Act var result = await sut.ExecuteHandshake("MAILBOX"); + + // Assert Assert.IsFalse(result); } [TestMethod] public async Task ShouldExecuteHandshake_NotImplemented_Throws() { + // Arrange var sut = new MeshPoller(_logger.Object, _meshOps.Object); + + // Act & Assert await Assert.ThrowsExceptionAsync(() => sut.ShouldExecuteHandshake("MAILBOX", "config.json")); } } From 5ec557edd8c1797d4eab639de89b5eba4173bc6b Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 13:55:28 +0100 Subject: [PATCH 34/49] fix: added XML docs for methods re Contributing.md --- .../ManageCaasSubscription.cs | 28 +++++++++++++++++++ .../ManageCaasSubscriptionConfig.cs | 16 +++++++++-- .../Common/Extensions/MeshMailboxExtension.cs | 15 ++++++++++ .../Common/Mesh/IMeshSendCaasSubscribe.cs | 10 +++++++ .../Shared/Common/Mesh/MeshConfig.cs | 16 +++++++++++ .../Shared/Common/Mesh/MeshPoller.cs | 15 +++++++++- .../Common/Mesh/MeshSendCaasSubscribe.cs | 10 +++++++ .../Mesh/MeshSendCaasSubscribeConfig.cs | 4 +++ .../Common/Utilities/CertificateHelper.cs | 8 ++++++ 9 files changed, 119 insertions(+), 3 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs index d05dabb019..34bb6d247b 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -13,6 +13,9 @@ namespace NHS.CohortManager.DemographicServices; using Model; using NHS.CohortManager.DemographicServices; +/// +/// Azure Functions endpoints for managing CaaS subscriptions via MESH and data services. +/// public class ManageCaasSubscription { private readonly ILogger _logger; @@ -41,6 +44,11 @@ public ManageCaasSubscription( _meshPoller = meshPoller; } + /// + /// Creates a new CaaS subscription for the given NHS number and persists a record. + /// + /// HTTP request containing an nhsNumber query parameter. + /// HTTP 200 on success, 400 for invalid input, or 500 on error. [Function("Subscribe")] public async Task Subscribe([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) { @@ -83,6 +91,11 @@ public async Task Subscribe([HttpTrigger(AuthorizationLevel.An } } + /// + /// Stub endpoint to remove a CaaS subscription for the given NHS number. + /// + /// HTTP request containing an nhsNumber query parameter. + /// HTTP 200 for the stub, or 400 for invalid input. [Function("Unsubscribe")] public async Task Unsubscribe([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) { @@ -96,6 +109,11 @@ public async Task Unsubscribe([HttpTrigger(AuthorizationLevel. return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, "Stub: CAAS subscription would be removed."); } + /// + /// Checks subscription status for a given NHS number. + /// + /// HTTP request containing an nhsNumber query parameter. + /// HTTP 200 when an active subscription is found, 404 if not, or 400/500 on error. [Function("CheckSubscriptionStatus")] public async Task CheckSubscriptionStatus([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) { @@ -128,6 +146,12 @@ public async Task CheckSubscriptionStatus([HttpTrigger(Authori } } + /// + /// Pass-through data service endpoint for CRUD operations on the NEMS subscription data object. + /// + /// HTTP request containing payload and route parameters. + /// Optional key or route tail for the data service. + /// HTTP response from the underlying data service handler. [Function("NemsSubscriptionDataService")] public async Task NemsSubscriptionDataService([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", "put", "delete", Route = "NemsSubscriptionDataService/{*key}")] HttpRequestData req, string? key) { @@ -144,6 +168,10 @@ public async Task NemsSubscriptionDataService([HttpTrigger(Aut } } + /// + /// Nightly timer trigger to validate the configured MESH mailbox via handshake. + /// + /// Timer trigger context. [Function("PollMeshMailbox")] public async Task RunAsync([TimerTrigger("59 23 * * *")] TimerInfo myTimer) { diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs index 9a11e88cf1..cce844d3bb 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs @@ -2,23 +2,35 @@ namespace NHS.CohortManager.DemographicServices; using System.ComponentModel.DataAnnotations; -// Minimal config object for ManageCaasSubscription. +/// +/// Configuration for the ManageCaasSubscription function app and MESH connectivity. +/// public class ManageCaasSubscriptionConfig { + /// Base URL for the MESH API. [Required] public string? MeshApiBaseUrl { get; set; } + /// Optional Azure Key Vault URL for certificate and secret retrieval. public string? KeyVaultConnectionString { get; set; } + /// Bypass server certificate validation for local/dev purposes. public bool BypassServerCertificateValidation { get; set; } = false; + /// Key Vault secret name or local path for CA certificates used to validate the MESH server. public string? MeshCACertName { get; set; } + /// Key Vault certificate name or local path for the client certificate. public string? MeshCaasKeyName { get; set; } + /// Passphrase for the client certificate, if required. public string? MeshCaasKeyPassword { get; set; } + /// MESH mailbox password for client authentication. public string? MeshCaasPassword { get; set; } + /// MESH shared key for HMAC authentication. public required string MeshCaasSharedKey { get; set; } + /// Destination mailbox for sending CAAS subscription messages. [Required] public string? CaasToMailbox { get; set; } + /// Source mailbox used for sending CAAS subscription messages. [Required] public string? CaasFromMailbox { get; set; } - // Controls whether shared implementations should use stubbed behavior + /// Controls whether shared implementations use stubbed behavior. public bool IsStubbed { get; set; } = true; } diff --git a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs index e578aa5a6d..ddde8fe546 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs @@ -9,9 +9,18 @@ namespace Common; using Microsoft.Extensions.Logging; using NHS.MESH.Client; using NHS.MESH.Client.Configuration; +/// +/// Service registration helpers for configuring MESH clients and mailboxes. +/// public static class MeshMailboxExtension { private static ILogger _logger; + /// + /// Registers the MESH client and configured mailboxes for dependency injection. + /// + /// The host builder to extend. + /// Configuration for MESH and mailboxes. + /// The original host builder. public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshConfig config) { ILoggerFactory factory = LoggerFactory.Create(builder => @@ -68,6 +77,12 @@ public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshC } + /// + /// Retrieves server-side CA certificates from Key Vault or a local file path. + /// + /// Key Vault secret name or file path containing PEM certificates. + /// Optional Key Vault URI. + /// A certificate collection when available; otherwise null. public static async Task GetCACertificates(string meshCertName, string? keyVaultConnectionString) { if (!string.IsNullOrEmpty(keyVaultConnectionString)) diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs index eace189c7b..810b2c2548 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs @@ -1,6 +1,16 @@ namespace Common; +/// +/// Contract for sending CAAS subscription requests via MESH. +/// public interface IMeshSendCaasSubscribe { + /// + /// Sends a CAAS subscription request for the given NHS number. + /// + /// The patient NHS number. + /// Destination MESH mailbox ID. + /// Source MESH mailbox ID. + /// The MESH message ID on success; otherwise null. Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox); } diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs index e1abdfbac9..23374d838d 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshConfig.cs @@ -1,20 +1,36 @@ namespace Common; +/// +/// Root configuration for the shared MESH client and associated mailboxes. +/// public class MeshConfig { + /// Azure Key Vault URI for certificate and secret retrieval. public string? KeyVaultConnectionString { get; set; } + /// Key Vault secret name or local file path containing server CA certificates. public string? MeshCACertName { get; set; } + /// Bypass server certificate validation. Use only for local development. public bool BypassServerCertificateValidation { get; set; } = false; + /// Base URL for the MESH API. public required string MeshApiBaseUrl { get; set; } + /// Configured mailboxes to register with the client. public required List MailboxConfigs { get; set; } } +/// +/// Configuration for a specific MESH mailbox. +/// public class MailboxConfig { + /// The MESH mailbox identifier. public required string MailboxId { get; set; } + /// Key Vault certificate name or local path for the client certificate. public string? MeshKeyName { get; set; } + /// Passphrase for the client certificate, if required. public string? MeshKeyPassword { get; set; } + /// Password for the mailbox account. public string? MeshPassword { get; set; } + /// Shared key used for HMAC authentication. public required string SharedKey { get; set; } } diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs index 840f02b09e..f0f4cf1a17 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPoller.cs @@ -5,6 +5,9 @@ namespace Common; using Model; using NHS.MESH.Client.Contracts.Services; +/// +/// Coordinates periodic MESH mailbox operations such as handshake validation. +/// public class MeshPoller : IMeshPoller { private readonly ILogger _logger; @@ -16,12 +19,23 @@ public MeshPoller(ILogger logger, IMeshOperationService meshOperatio _meshOperationService = meshOperationService; } + /// + /// Determines if a handshake should be executed based on persisted state. + /// + /// The mailbox to validate. + /// A state/configuration file identifier. + /// True if a handshake should run; otherwise false. public async Task ShouldExecuteHandshake(string mailboxId, string configFileName) { await Task.CompletedTask; throw new NotImplementedException("ShouldExecuteHandshake is not yet implemented"); } + /// + /// Executes a MESH handshake for the provided mailbox. + /// + /// The mailbox to validate. + /// True when successful; otherwise false. public async Task ExecuteHandshake(string mailboxId) { var meshValidationResponse = await _meshOperationService.MeshHandshakeAsync(mailboxId); @@ -38,4 +52,3 @@ public async Task ExecuteHandshake(string mailboxId) } - diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs index 6d80de63a0..0bd5930c39 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs @@ -8,6 +8,9 @@ namespace Common; using ParquetSharp; using ParquetSharp.IO; +/// +/// Sends CAAS subscription requests via the MESH outbox service. +/// public class MeshSendCaasSubscribe : IMeshSendCaasSubscribe { private ILogger _logger; @@ -20,6 +23,13 @@ public MeshSendCaasSubscribe(ILogger logger, IMeshOutboxS _config = config.Value; } + /// + /// Sends a CAAS subscription request for a given NHS number. + /// + /// The patient NHS number. + /// Destination MESH mailbox ID. + /// Source MESH mailbox ID. + /// The MESH message ID on success; otherwise null. public async Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox) { diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs index f670087086..b285bbd0d9 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeConfig.cs @@ -1,6 +1,10 @@ namespace Common; +/// +/// Settings for sending CAAS subscribe messages over MESH. +/// public class MeshSendCaasSubscribeConfig { + /// The workflow identifier to apply to outbound CAAS messages. public required string SendCaasWorkflowId { get; set; } } diff --git a/application/CohortManager/src/Functions/Shared/Common/Utilities/CertificateHelper.cs b/application/CohortManager/src/Functions/Shared/Common/Utilities/CertificateHelper.cs index 2e79fa1a53..865035265f 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Utilities/CertificateHelper.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Utilities/CertificateHelper.cs @@ -2,8 +2,16 @@ namespace Common; using System.Security.Cryptography.X509Certificates; +/// +/// Helpers for parsing certificates from PEM content. +/// public static class CertificateHelper { + /// + /// Parses one or more PEM-encoded certificates from a string into a collection. + /// + /// A string containing one or more concatenated PEM certificates. + /// An containing parsed certificates. public static X509Certificate2Collection GetCertificatesFromString(string certificatesString) { X509Certificate2Collection certs = []; From 8d66e125f298a431354a756f0fc0f78901b7c19a Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 15:54:59 +0100 Subject: [PATCH 35/49] fix: sonar issues including nullable warnings, glob syntax, unused vars --- .../ManageCaasSubscription/Dockerfile | 3 +-- .../ManageCaasSubscriptionConfig.cs | 1 + .../ManageCaasSubscription/Program.cs | 16 +++++------ .../Common/Extensions/MeshMailboxExtension.cs | 27 ++++++++++--------- .../Common/Mesh/MeshSendCaasSubscribe.cs | 11 +++++--- 5 files changed, 31 insertions(+), 27 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile index a3aca8f141..5216eea448 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Dockerfile @@ -18,7 +18,7 @@ COPY ./DemographicServices/ManageCaasSubscription /src/dotnet-function-app WORKDIR /src/dotnet-function-app RUN --mount=type=cache,target=/root/.nuget/packages \ - dotnet publish *.csproj --output /home/site/wwwroot + dotnet publish ./*.csproj --output /home/site/wwwroot # To enable ssh & remote debugging on app service change the base image to the one below # FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0-appservice @@ -27,4 +27,3 @@ ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ AzureFunctionsJobHost__Logging__Console__IsEnabled=true COPY --from=function ["/home/site/wwwroot", "/home/site/wwwroot"] - diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs index cce844d3bb..c9b2c4eeb6 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs @@ -23,6 +23,7 @@ public class ManageCaasSubscriptionConfig /// MESH mailbox password for client authentication. public string? MeshCaasPassword { get; set; } /// MESH shared key for HMAC authentication. + [Required] public required string MeshCaasSharedKey { get; set; } /// Destination mailbox for sending CAAS subscription messages. [Required] diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs index 7c1c3545ee..afbe516870 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs @@ -7,23 +7,23 @@ using DataServices.Database; using DataServices.Core; -var host = new HostBuilder() +var hostBuilder = new HostBuilder() .ConfigureFunctionsWebApplication() - .AddConfiguration(out ManageCaasSubscriptionConfig config) + .AddConfiguration(out ManageCaasSubscriptionConfig? config) .AddMeshMailboxes(new MeshConfig { - MeshApiBaseUrl = config.MeshApiBaseUrl, + MeshApiBaseUrl = config.MeshApiBaseUrl!, KeyVaultConnectionString = config.KeyVaultConnectionString, BypassServerCertificateValidation = config.BypassServerCertificateValidation, MailboxConfigs = new List { new MailboxConfig { - MailboxId = config.CaasFromMailbox, - MeshKeyName = config.MeshCaasKeyName, + MailboxId = config.CaasFromMailbox!, + MeshKeyName = config.MeshCaasKeyName!, MeshKeyPassword = config.MeshCaasKeyPassword, MeshPassword = config.MeshCaasPassword, - SharedKey = config.MeshCaasSharedKey + SharedKey = config.MeshCaasSharedKey! } } @@ -45,7 +45,7 @@ .AddDataServicesHandler() .AddHttpClient() .AddTelemetry() - .AddExceptionHandler() - .Build(); + .AddExceptionHandler(); +var host = hostBuilder.Build(); await host.RunAsync(); diff --git a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs index ddde8fe546..cda0a6f242 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs @@ -14,7 +14,6 @@ namespace Common; /// public static class MeshMailboxExtension { - private static ILogger _logger; /// /// Registers the MESH client and configured mailboxes for dependency injection. /// @@ -28,7 +27,7 @@ public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshC builder.AddConsole(); builder.AddApplicationInsights(); }); - _logger = factory.CreateLogger("MeshMailboxExtension"); + var logger = factory.CreateLogger("MeshMailboxExtension"); hostBuilder.ConfigureServices(async services => { @@ -40,8 +39,8 @@ public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshC foreach (var mailbox in config.MailboxConfigs) { - var cert = await GetCertificate(mailbox.MeshKeyName, mailbox.MeshKeyPassword, config.KeyVaultConnectionString); - var serverSideCerts = await GetCACertificates(config.MeshCACertName, config.KeyVaultConnectionString); + var cert = await GetCertificate(logger, mailbox.MeshKeyName, mailbox.MeshKeyPassword, config.KeyVaultConnectionString); + var serverSideCerts = await GetCACertificates(logger, config.MeshCACertName, config.KeyVaultConnectionString); meshClientBuilder.AddMailbox(mailbox.MailboxId, new MailboxConfiguration { Password = mailbox.MeshPassword, @@ -51,26 +50,27 @@ public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshC }); } - services = meshClientBuilder.Build(); + meshClientBuilder.Build(); }); return hostBuilder; } - private static async Task GetCertificate(string meshKeyName, string? meshKeyPassphrase, string? keyVaultConnectionString) + private static async Task GetCertificate(ILogger logger, string? meshKeyName, string? meshKeyPassphrase, string? keyVaultConnectionString) { if (!string.IsNullOrEmpty(keyVaultConnectionString)) { - _logger.LogInformation("Pulling Mesh Certificate from KeyVault"); + logger.LogInformation("Pulling Mesh Certificate from KeyVault"); var certClient = new CertificateClient(vaultUri: new Uri(keyVaultConnectionString), credential: new DefaultAzureCredential()); + if (string.IsNullOrWhiteSpace(meshKeyName)) return null; var certificate = await certClient.DownloadCertificateAsync(meshKeyName); return certificate.Value; } - if (!string.IsNullOrEmpty(meshKeyName) || Path.Exists(meshKeyName)) + if (!string.IsNullOrWhiteSpace(meshKeyName) && Path.Exists(meshKeyName)) { - _logger.LogInformation("Pulling Mesh Certificate from local File"); + logger.LogInformation("Pulling Mesh Certificate from local File"); return new X509Certificate2(meshKeyName!, meshKeyPassphrase); } return null; @@ -83,17 +83,18 @@ public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshC /// Key Vault secret name or file path containing PEM certificates. /// Optional Key Vault URI. /// A certificate collection when available; otherwise null. - public static async Task GetCACertificates(string meshCertName, string? keyVaultConnectionString) + public static async Task GetCACertificates(ILogger logger, string? meshCertName, string? keyVaultConnectionString) { if (!string.IsNullOrEmpty(keyVaultConnectionString)) { var secretClient = new SecretClient(vaultUri: new Uri(keyVaultConnectionString), credential: new DefaultAzureCredential()); - string base64Cert = secretClient.GetSecret(meshCertName).Value.Value; + if (string.IsNullOrWhiteSpace(meshCertName)) return null; + var secret = await secretClient.GetSecretAsync(meshCertName); + string base64Cert = secret.Value.Value; return CertificateHelper.GetCertificatesFromString(base64Cert); } - if (!string.IsNullOrEmpty(meshCertName) || Path.Exists(meshCertName)) + if (!string.IsNullOrWhiteSpace(meshCertName) && Path.Exists(meshCertName)) { - string certsString = await File.ReadAllTextAsync(meshCertName); return CertificateHelper.GetCertificatesFromString(certsString); } diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs index 0bd5930c39..5759b94d03 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs @@ -13,9 +13,9 @@ namespace Common; /// public class MeshSendCaasSubscribe : IMeshSendCaasSubscribe { - private ILogger _logger; - private IMeshOutboxService _meshOutboxService; - private MeshSendCaasSubscribeConfig _config; + private readonly ILogger _logger; + private readonly IMeshOutboxService _meshOutboxService; + private readonly MeshSendCaasSubscribeConfig _config; public MeshSendCaasSubscribe(ILogger logger, IMeshOutboxService meshOutboxService, IOptions config) { _logger = logger; @@ -45,7 +45,10 @@ public async Task SendSubscriptionRequest(long nhsNumber, string toMailb var result = await _meshOutboxService.SendCompressedMessageAsync(fromMailbox, toMailbox, _config.SendCaasWorkflowId, file); if (!result.IsSuccessful) { - _logger.LogError("Could't send mesh message Error Code: {ErrorCode}, Error Description: {ErrorDescription}, ", result.Error.ErrorCode, result.Error.ErrorDescription); + _logger.LogError( + "Could not send MESH message. Error Code: {ErrorCode}, Error Description: {ErrorDescription}", + result.Error?.ErrorCode, + result.Error?.ErrorDescription); return null; } From a6c13b0abab259a002a064d90413dd90ea6b8894 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Tue, 2 Sep 2025 16:12:12 +0100 Subject: [PATCH 36/49] fix: method signatures in unit tests --- .../ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs index 71379b55a5..eaa486c97a 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/MeshMailboxExtensionTests.cs @@ -5,6 +5,7 @@ namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; using System.IO; using System; using Common; +using Microsoft.Extensions.Logging.Abstractions; [TestClass] public class MeshMailboxExtensionTests @@ -28,7 +29,7 @@ public async Task GetCACertificates_FromFilePath_ReturnsCollection() try { // Act - var certs = await MeshMailboxExtension.GetCACertificates(tempPath, null); + var certs = await MeshMailboxExtension.GetCACertificates(NullLogger.Instance, tempPath, null); // Assert Assert.IsNotNull(certs); @@ -47,7 +48,7 @@ public async Task GetCACertificates_NoInputs_ReturnsNull() // Arrange: no inputs // Act - var certs = await MeshMailboxExtension.GetCACertificates(null, null); + var certs = await MeshMailboxExtension.GetCACertificates(NullLogger.Instance, null, null); // Assert Assert.IsNull(certs); From b2a2e04ea746565705136c98e732507012a81c90 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 13:55:42 +0100 Subject: [PATCH 37/49] fix: removed nullable values from ManageCaasSubscriptionConfig to align with Required tags --- .../ManageCaasSubscriptionConfig.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs index c9b2c4eeb6..ee141217db 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs @@ -9,7 +9,7 @@ public class ManageCaasSubscriptionConfig { /// Base URL for the MESH API. [Required] - public string? MeshApiBaseUrl { get; set; } + public string MeshApiBaseUrl { get; set; } /// Optional Azure Key Vault URL for certificate and secret retrieval. public string? KeyVaultConnectionString { get; set; } /// Bypass server certificate validation for local/dev purposes. @@ -24,13 +24,13 @@ public class ManageCaasSubscriptionConfig public string? MeshCaasPassword { get; set; } /// MESH shared key for HMAC authentication. [Required] - public required string MeshCaasSharedKey { get; set; } + public string MeshCaasSharedKey { get; set; } /// Destination mailbox for sending CAAS subscription messages. [Required] - public string? CaasToMailbox { get; set; } + public string CaasToMailbox { get; set; } /// Source mailbox used for sending CAAS subscription messages. [Required] - public string? CaasFromMailbox { get; set; } + public string CaasFromMailbox { get; set; } /// Controls whether shared implementations use stubbed behavior. public bool IsStubbed { get; set; } = true; From e963312838c97c4cb8f072a64c2cf705b83f86d5 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 13:57:06 +0100 Subject: [PATCH 38/49] fix: added string? nullable flag to SendSubscriptionRequest method response --- .../src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs | 2 +- .../src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs index 810b2c2548..e183288f0e 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/IMeshSendCaasSubscribe.cs @@ -12,5 +12,5 @@ public interface IMeshSendCaasSubscribe /// Destination MESH mailbox ID. /// Source MESH mailbox ID. /// The MESH message ID on success; otherwise null. - Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox); + Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox); } diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs index 5759b94d03..ccc32ee16f 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribe.cs @@ -30,7 +30,7 @@ public MeshSendCaasSubscribe(ILogger logger, IMeshOutboxS /// Destination MESH mailbox ID. /// Source MESH mailbox ID. /// The MESH message ID on success; otherwise null. - public async Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox) + public async Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox) { var content = CreateParquetFile(nhsNumber); From 3300269c50e52f098558b2896a90c4a14a696341 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 14:00:33 +0100 Subject: [PATCH 39/49] fix: added null check to message id, implemented exception handler into ManageCaasSubscription --- .../ManageCaasSubscription.cs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs index 34bb6d247b..4308c7915e 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -12,6 +12,7 @@ namespace NHS.CohortManager.DemographicServices; using DataServices.Core; using Model; using NHS.CohortManager.DemographicServices; +using Common.Interfaces; /// /// Azure Functions endpoints for managing CaaS subscriptions via MESH and data services. @@ -25,6 +26,7 @@ public class ManageCaasSubscription private readonly IRequestHandler _requestHandler; private readonly IDataServiceAccessor _nemsSubscriptionAccessor; private readonly IMeshPoller _meshPoller; + private readonly IExceptionHandler _exceptionHandler; public ManageCaasSubscription( ILogger logger, @@ -33,7 +35,8 @@ public ManageCaasSubscription( IMeshSendCaasSubscribe meshSendCaasSubscribe, IRequestHandler requestHandler, IDataServiceAccessor nemsSubscriptionAccessor, - IMeshPoller meshPoller) + IMeshPoller meshPoller, + IExceptionHandler exceptionHandler) { _logger = logger; _createResponse = createResponse; @@ -42,8 +45,10 @@ public ManageCaasSubscription( _requestHandler = requestHandler; _nemsSubscriptionAccessor = nemsSubscriptionAccessor; _meshPoller = meshPoller; + _exceptionHandler = exceptionHandler; } + /// /// Creates a new CaaS subscription for the given NHS number and persists a record. /// @@ -60,12 +65,19 @@ public async Task Subscribe([HttpTrigger(AuthorizationLevel.An return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.BadRequest, req, "NHS number is required and must be valid format."); } - // Forward to MeshSendCaasSubscribeStub (Shared) var nhsNo = long.Parse(nhsNumber!); var toMailbox = _config.CaasToMailbox!; var fromMailbox = _config.CaasFromMailbox!; var messageId = await _meshSendCaasSubscribe.SendSubscriptionRequest(nhsNo, toMailbox, fromMailbox); + if (string.IsNullOrEmpty(messageId)) + { + var ex = new InvalidOperationException("Failed to send CAAS subscription via MESH"); + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsNo.ToString(), nameof(ManageCaasSubscription), "CAAS", $"to={toMailbox}"); + _logger.LogError("Failed to send CAAS subscription via MESH"); + return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Failed to send CAAS subscription via MESH."); + } + // Save a record to NEMS_SUBSCRIPTION table with source = MESH var record = new NemsSubscription { @@ -77,11 +89,20 @@ public async Task Subscribe([HttpTrigger(AuthorizationLevel.An var saved = await _nemsSubscriptionAccessor.InsertSingle(record); if (!saved) { + var ex = new InvalidOperationException("Failed to save CAAS subscription record to database"); + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsNo.ToString(), nameof(ManageCaasSubscription), "CAAS", System.Text.Json.JsonSerializer.Serialize(record)); _logger.LogError("Failed to write CAAS subscription record to database"); return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Failed to save subscription record."); } - _logger.LogInformation("CAAS Subscribe forwarded to Mesh stub. MessageId: {Msg}", messageId); + if (_config.IsStubbed) + { + _logger.LogInformation("CAAS Subscribe forwarded to MESH stub. MessageId: {Msg}", messageId); + } + else + { + _logger.LogInformation("CAAS Subscribe sent to MESH. MessageId: {Msg}", messageId); + } return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.OK, req, $"Subscription request accepted. MessageId: {messageId}"); } catch (Exception ex) From 0d45c9b175507e7929f9ad6f18a4ba7c6d55395f Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 14:04:23 +0100 Subject: [PATCH 40/49] fix: switched from DefaultAzureCredential to ManagedIdentityCredential in MeshMailboxExtension --- .../Shared/Common/Extensions/MeshMailboxExtension.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs index cda0a6f242..c8cad4197c 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Extensions/MeshMailboxExtension.cs @@ -62,7 +62,7 @@ public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshC if (!string.IsNullOrEmpty(keyVaultConnectionString)) { logger.LogInformation("Pulling Mesh Certificate from KeyVault"); - var certClient = new CertificateClient(vaultUri: new Uri(keyVaultConnectionString), credential: new DefaultAzureCredential()); + var certClient = new CertificateClient(vaultUri: new Uri(keyVaultConnectionString), credential: new ManagedIdentityCredential()); if (string.IsNullOrWhiteSpace(meshKeyName)) return null; var certificate = await certClient.DownloadCertificateAsync(meshKeyName); return certificate.Value; @@ -87,7 +87,7 @@ public static IHostBuilder AddMeshMailboxes(this IHostBuilder hostBuilder, MeshC { if (!string.IsNullOrEmpty(keyVaultConnectionString)) { - var secretClient = new SecretClient(vaultUri: new Uri(keyVaultConnectionString), credential: new DefaultAzureCredential()); + var secretClient = new SecretClient(vaultUri: new Uri(keyVaultConnectionString), credential: new ManagedIdentityCredential()); if (string.IsNullOrWhiteSpace(meshCertName)) return null; var secret = await secretClient.GetSecretAsync(meshCertName); string base64Cert = secret.Value.Value; From 59cdd67e9c51329fc7f61914dce8499c7cce000c Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 14:06:06 +0100 Subject: [PATCH 41/49] fix: dev tfvars for ManageCaasSubscription; added db and kv connection strings, retained 'isStubbed' --- infrastructure/tf-core/environments/development.tfvars | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/infrastructure/tf-core/environments/development.tfvars b/infrastructure/tf-core/environments/development.tfvars index 3d1e6ee4e8..f8ba779974 100644 --- a/infrastructure/tf-core/environments/development.tfvars +++ b/infrastructure/tf-core/environments/development.tfvars @@ -1060,16 +1060,17 @@ function_apps = { name_suffix = "manage-caas-subscription" function_endpoint_name = "ManageCaasSubscription" app_service_plan_key = "NonScaling" + db_connection_string = "DtOsDatabaseConnectionString" + key_vault_url = "KeyVaultConnectionString" + env_vars_static = { + IsStubbed = "true" + } app_urls = [ { env_var_name = "ExceptionFunctionURL" function_app_key = "CreateException" } ] - env_vars_static = { - # Minimal stubbed config; expand later if needed - IsStubbed = "true" - } } ReferenceDataService = { From c891d7a7c4175c62b0fd767d97d619e2e1dfeb7a Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 14:06:41 +0100 Subject: [PATCH 42/49] fix: updated unit tests to include exception handler --- .../ManageCaasSubscriptionTests.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index 06113ccd28..d8a69229a4 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -15,6 +15,7 @@ namespace NHS.CohortManager.Tests.UnitTests.DemographicServicesTests; using Model; using System.ComponentModel.DataAnnotations; using System.Linq; +using Common.Interfaces; [TestClass] public class ManageCaasSubscriptionTests @@ -28,6 +29,7 @@ public class ManageCaasSubscriptionTests private readonly Mock> _requestHandler = new(); private readonly Mock> _nemsAccessor = new(); private readonly Mock _meshPoller = new(); + private readonly Mock _exceptionHandler = new(); public ManageCaasSubscriptionTests() { @@ -43,21 +45,22 @@ public ManageCaasSubscriptionTests() .Setup(m => m.SendSubscriptionRequest(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync("STUB_MSG_ID"); - // Default: pretend a subscription exists so generic tests expect OK _nemsAccessor .Setup(a => a.GetSingle(It.IsAny>>() )) .ReturnsAsync(new NemsSubscription { NhsNumber = 9000000009, SubscriptionId = "SUB123" }); - // Default: request handler returns OK for data-service calls _requestHandler .Setup(r => r.HandleRequest(It.IsAny(), It.IsAny())) .ReturnsAsync((HttpRequestData r, string k) => _createResponse.CreateHttpResponse(HttpStatusCode.OK, r, "OK")); - // Default: DB insert succeeds for subscribe happy path _nemsAccessor .Setup(a => a.InsertSingle(It.IsAny())) .ReturnsAsync(true); + _exceptionHandler + .Setup(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + _sut = new ManageCaasSubscription( _logger.Object, _createResponse, @@ -65,7 +68,8 @@ public ManageCaasSubscriptionTests() _mesh.Object, _requestHandler.Object, _nemsAccessor.Object, - _meshPoller.Object + _meshPoller.Object, + _exceptionHandler.Object ); } @@ -231,7 +235,6 @@ public async Task Subscribe_DBInsertFails_ReturnsInternalServerError() [TestMethod] public async Task PollMeshMailbox_UsesConfigFromMailbox() { - // Ensure config has expected mailbox _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig { CaasFromMailbox = "TEST_FROM", @@ -247,7 +250,8 @@ public async Task PollMeshMailbox_UsesConfigFromMailbox() _mesh.Object, _requestHandler.Object, _nemsAccessor.Object, - _meshPoller.Object + _meshPoller.Object, + _exceptionHandler.Object ); await sut.RunAsync(null); From a87cd99a244066172f1997eac95633c691a06a3f Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 14:09:09 +0100 Subject: [PATCH 43/49] fix: extended stub in ManageCaasSubscription to cover polling, and corrected return type in SendSubscriptionRequest stub --- .../ManageCaasSubscription/Program.cs | 10 +++++----- .../Functions/Shared/Common/Mesh/MeshPollerStub.cs | 14 ++++++++++++++ .../Common/Mesh/MeshSendCaasSubscribeStub.cs | 2 +- 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPollerStub.cs diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs index afbe516870..1505792e3f 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs @@ -12,19 +12,18 @@ .AddConfiguration(out ManageCaasSubscriptionConfig? config) .AddMeshMailboxes(new MeshConfig { - MeshApiBaseUrl = config.MeshApiBaseUrl!, + MeshApiBaseUrl = config!.MeshApiBaseUrl, KeyVaultConnectionString = config.KeyVaultConnectionString, BypassServerCertificateValidation = config.BypassServerCertificateValidation, MailboxConfigs = new List { new MailboxConfig { - MailboxId = config.CaasFromMailbox!, + MailboxId = config.CaasFromMailbox, MeshKeyName = config.MeshCaasKeyName!, MeshKeyPassword = config.MeshCaasKeyPassword, MeshPassword = config.MeshCaasPassword, - SharedKey = config.MeshCaasSharedKey! - + SharedKey = config.MeshCaasSharedKey } } }) @@ -35,12 +34,13 @@ if (config.IsStubbed) { services.AddSingleton(); + services.AddSingleton(); } else { services.AddScoped(); + services.AddScoped(); } - services.AddScoped(); }) .AddDataServicesHandler() .AddHttpClient() diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPollerStub.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPollerStub.cs new file mode 100644 index 0000000000..e6a02b0474 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshPollerStub.cs @@ -0,0 +1,14 @@ +namespace Common; + +public class MeshPollerStub : IMeshPoller +{ + public Task ExecuteHandshake(string mailboxId) + { + return Task.FromResult(true); + } + + public Task ShouldExecuteHandshake(string mailboxId, string configFileName) + { + return Task.FromResult(false); + } +} diff --git a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs index fb4db3d7e5..ab20fec508 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Mesh/MeshSendCaasSubscribeStub.cs @@ -4,7 +4,7 @@ namespace Common; public class MeshSendCaasSubscribeStub : IMeshSendCaasSubscribe { - public async Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox) + public async Task SendSubscriptionRequest(long nhsNumber, string toMailbox, string fromMailbox) { await Task.CompletedTask; return $"STUB_{Guid.NewGuid():N}"; From 8c09edd1c028238459d11e0c3e7b5a3a57a6711a Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 14:42:40 +0100 Subject: [PATCH 44/49] fix: added required keyword to required values in ManageCaasSubscriptionConfig --- .../ManageCaasSubscriptionConfig.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs index ee141217db..58aede701d 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscriptionConfig.cs @@ -9,7 +9,7 @@ public class ManageCaasSubscriptionConfig { /// Base URL for the MESH API. [Required] - public string MeshApiBaseUrl { get; set; } + public required string MeshApiBaseUrl { get; set; } /// Optional Azure Key Vault URL for certificate and secret retrieval. public string? KeyVaultConnectionString { get; set; } /// Bypass server certificate validation for local/dev purposes. @@ -24,14 +24,14 @@ public class ManageCaasSubscriptionConfig public string? MeshCaasPassword { get; set; } /// MESH shared key for HMAC authentication. [Required] - public string MeshCaasSharedKey { get; set; } + public required string MeshCaasSharedKey { get; set; } /// Destination mailbox for sending CAAS subscription messages. [Required] - public string CaasToMailbox { get; set; } + public required string CaasToMailbox { get; set; } /// Source mailbox used for sending CAAS subscription messages. [Required] - public string CaasFromMailbox { get; set; } + public required string CaasFromMailbox { get; set; } /// Controls whether shared implementations use stubbed behavior. - public bool IsStubbed { get; set; } = true; + public bool IsStubbed { get; set; } } From e481d6ba93d09c28e4463ce462628da68610bcea Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 14:52:23 +0100 Subject: [PATCH 45/49] fix: log to exception handler in main catch block in Subscribe endpoint for ManageCaasSubscription --- .../ManageCaasSubscription.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs index 4308c7915e..f36342bc7f 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -73,7 +73,7 @@ public async Task Subscribe([HttpTrigger(AuthorizationLevel.An if (string.IsNullOrEmpty(messageId)) { var ex = new InvalidOperationException("Failed to send CAAS subscription via MESH"); - await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsNo.ToString(), nameof(ManageCaasSubscription), "CAAS", $"to={toMailbox}"); + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsNo.ToString(), nameof(ManageCaasSubscription), "", $"to={toMailbox}"); _logger.LogError("Failed to send CAAS subscription via MESH"); return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Failed to send CAAS subscription via MESH."); } @@ -90,7 +90,7 @@ public async Task Subscribe([HttpTrigger(AuthorizationLevel.An if (!saved) { var ex = new InvalidOperationException("Failed to save CAAS subscription record to database"); - await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsNo.ToString(), nameof(ManageCaasSubscription), "CAAS", System.Text.Json.JsonSerializer.Serialize(record)); + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsNo.ToString(), nameof(ManageCaasSubscription), "", System.Text.Json.JsonSerializer.Serialize(record)); _logger.LogError("Failed to write CAAS subscription record to database"); return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "Failed to save subscription record."); } @@ -108,6 +108,16 @@ public async Task Subscribe([HttpTrigger(AuthorizationLevel.An catch (Exception ex) { _logger.LogError(ex, "Error sending CAAS subscribe request"); + try + { + string? rawNhs = req.Query["nhsNumber"]; + var nhsForLog = ValidationHelper.ValidateNHSNumber(rawNhs!) ? rawNhs! : string.Empty; + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsForLog, nameof(ManageCaasSubscription), "", string.Empty); + } + catch + { + // Swallow secondary errors to preserve primary failure path + } return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "An error occurred while sending the CAAS subscription request."); } } From 2f311f49db01825e70d31809161cb05171aea3ce Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 15:20:56 +0100 Subject: [PATCH 46/49] fix: covered all catch blocks with exception logs to handler within ManageCaasSubscription function --- .../ManageCaasSubscription.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs index f36342bc7f..1efd7165d9 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -173,6 +173,16 @@ public async Task CheckSubscriptionStatus([HttpTrigger(Authori catch (Exception ex) { _logger.LogError(ex, "Error checking subscription status"); + try + { + string? rawNhs = req.Query["nhsNumber"]; + var nhsForLog = ValidationHelper.ValidateNHSNumber(rawNhs!) ? rawNhs! : string.Empty; + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsForLog, nameof(ManageCaasSubscription), "CAAS", string.Empty); + } + catch + { + // Swallow secondary errors to preserve primary failure path + } return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "An error occurred while checking subscription status."); } } @@ -195,6 +205,14 @@ public async Task NemsSubscriptionDataService([HttpTrigger(Aut catch (Exception ex) { _logger.LogError(ex, "An error has occurred in data service"); + try + { + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, string.Empty, nameof(ManageCaasSubscription), "CAAS", string.Empty); + } + catch + { + // Swallow secondary errors to preserve primary failure path + } return await _createResponse.CreateHttpResponseWithBodyAsync(HttpStatusCode.InternalServerError, req, "An error occurred while processing the data service request."); } } From 18d2e66a9eb1f5145f8dead6929662597f8f4ca8 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 15:27:36 +0100 Subject: [PATCH 47/49] feat: added explicit stubbed log on startup to make it clear upon diagnosis --- .../ManageCaasSubscription/Program.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs index 1505792e3f..06836de663 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/Program.cs @@ -47,5 +47,17 @@ .AddTelemetry() .AddExceptionHandler(); +// Log startup mode for visibility +var startupLoggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); +var startupLogger = startupLoggerFactory.CreateLogger("ManageCaasSubscription.Program"); +if (config!.IsStubbed) +{ + startupLogger.LogWarning("ManageCaasSubscription starting in STUBBED mode: using MeshSendCaasSubscribeStub and MeshPollerStub."); +} +else +{ + startupLogger.LogInformation("ManageCaasSubscription starting in LIVE mode: using real MESH services."); +} + var host = hostBuilder.Build(); await host.RunAsync(); From fca17ccecc9febf4a8c9db1828680c51b2128bcd Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 15:28:39 +0100 Subject: [PATCH 48/49] test: Added unit tests around all code flows, removed validation tests as no longer relevant, added asserts for exception handler logs. --- .../ManageCaasSubscriptionTests.cs | 121 ++++++++++++++---- 1 file changed, 93 insertions(+), 28 deletions(-) diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index d8a69229a4..d81232bf10 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -175,6 +175,7 @@ public async Task NemsSubscriptionDataService_HandlerThrows_ReturnsInternalServe .ThrowsAsync(new Exception("boom")); var res = await _sut.NemsSubscriptionDataService(req.Object, "key"); Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "", nameof(ManageCaasSubscription), "CAAS", It.IsAny()), Times.Once); } [TestMethod] @@ -198,6 +199,7 @@ public async Task CheckSubscriptionStatus_AccessorThrows_ReturnsInternalServerEr .ThrowsAsync(new Exception("db-error")); var res = await _sut.CheckSubscriptionStatus(req.Object); Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "CAAS", It.IsAny()), Times.Once); } [TestMethod] @@ -221,6 +223,7 @@ public async Task Subscribe_MeshThrows_ReturnsInternalServerError() var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); var res = await _sut.Subscribe(req.Object); Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "CAAS", It.IsAny()), Times.Once); } [TestMethod] @@ -230,17 +233,19 @@ public async Task Subscribe_DBInsertFails_ReturnsInternalServerError() var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); var res = await _sut.Subscribe(req.Object); Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "CAAS", It.IsAny()), Times.Once); } [TestMethod] - public async Task PollMeshMailbox_UsesConfigFromMailbox() + public async Task Subscribe_LogsStubMessage_WhenIsStubbedTrue() { _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig { - CaasFromMailbox = "TEST_FROM", CaasToMailbox = "TEST_TO", + CaasFromMailbox = "TEST_FROM", MeshApiBaseUrl = "http://localhost", - MeshCaasSharedKey = "dummy" + MeshCaasSharedKey = "dummy", + IsStubbed = true }); var sut = new ManageCaasSubscription( @@ -254,45 +259,105 @@ public async Task PollMeshMailbox_UsesConfigFromMailbox() _exceptionHandler.Object ); - await sut.RunAsync(null); - _meshPoller.Verify(p => p.ExecuteHandshake("TEST_FROM"), Times.Once); + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + _logger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("MESH stub")), + It.IsAny(), + It.IsAny>()), Times.Once); } [TestMethod] - public void Config_MissingMailboxes_FailsValidation() + public async Task Subscribe_LogsRealMessage_WhenIsStubbedFalse() { - var cfg = new ManageCaasSubscriptionConfig + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig { + CaasToMailbox = "TEST_TO", + CaasFromMailbox = "TEST_FROM", MeshApiBaseUrl = "http://localhost", - MeshCaasSharedKey = "dummy" - }; - var context = new ValidationContext(cfg); - var results = new System.Collections.Generic.List(); - var isValid = Validator.TryValidateObject(cfg, context, results, validateAllProperties: true); - Assert.IsFalse(isValid); - Assert.IsTrue(results.Any(r => r.MemberNames.Contains("CaasToMailbox"))); - Assert.IsTrue(results.Any(r => r.MemberNames.Contains("CaasFromMailbox"))); + MeshCaasSharedKey = "dummy", + IsStubbed = false + }); + + var sut = new ManageCaasSubscription( + _logger.Object, + _createResponse, + _config.Object, + _mesh.Object, + _requestHandler.Object, + _nemsAccessor.Object, + _meshPoller.Object, + _exceptionHandler.Object + ); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await sut.Subscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + _logger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("sent to MESH")), + It.IsAny(), + It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Unsubscribe_LogsStubMessage() + { + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Unsubscribe(req.Object); + Assert.AreEqual(HttpStatusCode.OK, res.StatusCode); + _logger.Verify(l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("[CAAS-Stub] Unsubscribe called")), + It.IsAny(), + It.IsAny>()), Times.Once); } [TestMethod] - public void Config_MissingMeshApiBaseUrl_FailsValidation() + public async Task Subscribe_MeshReturnsNull_ReturnsInternalServerError() { - // Arrange - var cfg = new ManageCaasSubscriptionConfig + _mesh + .Setup(m => m.SendSubscriptionRequest(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string?)null); + + var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); + var res = await _sut.Subscribe(req.Object); + + Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "CAAS", It.IsAny()), Times.Once); + _nemsAccessor.Verify(a => a.InsertSingle(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PollMeshMailbox_UsesConfigFromMailbox() + { + _config.Setup(x => x.Value).Returns(new ManageCaasSubscriptionConfig { - CaasToMailbox = "TEST_TO", CaasFromMailbox = "TEST_FROM", + CaasToMailbox = "TEST_TO", + MeshApiBaseUrl = "http://localhost", MeshCaasSharedKey = "dummy" - }; - - var context = new ValidationContext(cfg); - var results = new System.Collections.Generic.List(); + }); - // Act - var isValid = Validator.TryValidateObject(cfg, context, results, validateAllProperties: true); + var sut = new ManageCaasSubscription( + _logger.Object, + _createResponse, + _config.Object, + _mesh.Object, + _requestHandler.Object, + _nemsAccessor.Object, + _meshPoller.Object, + _exceptionHandler.Object + ); - // Assert - Assert.IsFalse(isValid); - Assert.IsTrue(results.Any(r => r.MemberNames.Contains("MeshApiBaseUrl"))); + await sut.RunAsync(null); + _meshPoller.Verify(p => p.ExecuteHandshake("TEST_FROM"), Times.Once); } + + } From 25d81b3f9c46ed25387156b4ab1e2e3aecb6a502 Mon Sep 17 00:00:00 2001 From: Sam Ainsworth Date: Thu, 4 Sep 2025 15:46:02 +0100 Subject: [PATCH 49/49] fix: removed remaining 'CAAS' filenames from logs as they're not correct - adjusted unit tests to match. --- .../ManageCaasSubscription/ManageCaasSubscription.cs | 4 ++-- .../ManageCaasSubscriptionTests.cs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs index 1efd7165d9..e16594e702 100644 --- a/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs +++ b/application/CohortManager/src/Functions/DemographicServices/ManageCaasSubscription/ManageCaasSubscription.cs @@ -177,7 +177,7 @@ public async Task CheckSubscriptionStatus([HttpTrigger(Authori { string? rawNhs = req.Query["nhsNumber"]; var nhsForLog = ValidationHelper.ValidateNHSNumber(rawNhs!) ? rawNhs! : string.Empty; - await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsForLog, nameof(ManageCaasSubscription), "CAAS", string.Empty); + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, nhsForLog, nameof(ManageCaasSubscription), "", string.Empty); } catch { @@ -207,7 +207,7 @@ public async Task NemsSubscriptionDataService([HttpTrigger(Aut _logger.LogError(ex, "An error has occurred in data service"); try { - await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, string.Empty, nameof(ManageCaasSubscription), "CAAS", string.Empty); + await _exceptionHandler.CreateSystemExceptionLogFromNhsNumber(ex, string.Empty, nameof(ManageCaasSubscription), "", string.Empty); } catch { diff --git a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs index d81232bf10..b682514394 100644 --- a/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs +++ b/tests/UnitTests/DemographicServicesTests/ManageCaasSubscriptionTests/ManageCaasSubscriptionTests.cs @@ -175,7 +175,7 @@ public async Task NemsSubscriptionDataService_HandlerThrows_ReturnsInternalServe .ThrowsAsync(new Exception("boom")); var res = await _sut.NemsSubscriptionDataService(req.Object, "key"); Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); - _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "", nameof(ManageCaasSubscription), "CAAS", It.IsAny()), Times.Once); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "", nameof(ManageCaasSubscription), "", It.IsAny()), Times.Once); } [TestMethod] @@ -199,7 +199,7 @@ public async Task CheckSubscriptionStatus_AccessorThrows_ReturnsInternalServerEr .ThrowsAsync(new Exception("db-error")); var res = await _sut.CheckSubscriptionStatus(req.Object); Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); - _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "CAAS", It.IsAny()), Times.Once); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "", It.IsAny()), Times.Once); } [TestMethod] @@ -223,7 +223,7 @@ public async Task Subscribe_MeshThrows_ReturnsInternalServerError() var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); var res = await _sut.Subscribe(req.Object); Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); - _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "CAAS", It.IsAny()), Times.Once); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "", It.IsAny()), Times.Once); } [TestMethod] @@ -233,7 +233,7 @@ public async Task Subscribe_DBInsertFails_ReturnsInternalServerError() var req = _setupRequest.Setup(null, new NameValueCollection { { "nhsNumber", "9000000009" } }, HttpMethod.Post); var res = await _sut.Subscribe(req.Object); Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); - _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "CAAS", It.IsAny()), Times.Once); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "", It.IsAny()), Times.Once); } [TestMethod] @@ -329,7 +329,7 @@ public async Task Subscribe_MeshReturnsNull_ReturnsInternalServerError() var res = await _sut.Subscribe(req.Object); Assert.AreEqual(HttpStatusCode.InternalServerError, res.StatusCode); - _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "CAAS", It.IsAny()), Times.Once); + _exceptionHandler.Verify(e => e.CreateSystemExceptionLogFromNhsNumber(It.IsAny(), "9000000009", nameof(ManageCaasSubscription), "", It.IsAny()), Times.Once); _nemsAccessor.Verify(a => a.InsertSingle(It.IsAny()), Times.Never); }