From 28bdb0a40dcfb36aac170ad8962a03e13799ba67 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Wed, 2 Apr 2025 17:48:52 +0100 Subject: [PATCH 01/20] feat: Created an endpoint for BSSelect to post data to --- src/.gitkeep | 0 .../Functions/BSSelectFunctions.cs | 76 +++++++++++++++++++ .../Models/BSSelectEpisodeEvent.cs | 20 +++++ src/ServiceLayer.API/Program.cs | 29 +++++++ .../Properties/launchSettings.json | 9 +++ src/ServiceLayer.API/ServiceLayer.API.csproj | 30 ++++++++ .../Shared/CreatePathwayParticipantDto.cs | 10 +++ src/ServiceLayer.API/host.json | 12 +++ src/ServiceLayer.sln | 26 +++++++ 9 files changed, 212 insertions(+) delete mode 100644 src/.gitkeep create mode 100644 src/ServiceLayer.API/Functions/BSSelectFunctions.cs create mode 100644 src/ServiceLayer.API/Models/BSSelectEpisodeEvent.cs create mode 100644 src/ServiceLayer.API/Program.cs create mode 100644 src/ServiceLayer.API/Properties/launchSettings.json create mode 100644 src/ServiceLayer.API/ServiceLayer.API.csproj create mode 100644 src/ServiceLayer.API/Shared/CreatePathwayParticipantDto.cs create mode 100644 src/ServiceLayer.API/host.json create mode 100644 src/ServiceLayer.sln diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs new file mode 100644 index 0000000..207be0e --- /dev/null +++ b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs @@ -0,0 +1,76 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Azure.Messaging; +using Azure.Messaging.EventGrid; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using ServiceLayer.API.Models; +using ServiceLayer.API.Shared; + +namespace ServiceLayer.API.Functions; + +public class BSSelectFunctions(ILogger logger, EventGridPublisherClient eventGridPublisherClient) +{ + [Function("CreateEpisodeEvent")] + public async Task CreateEpisodeEvent([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bsselect/episodes")] HttpRequest req) + { + BSSelectEpisodeEvent? bssEpisodeEvent; + + try + { + bssEpisodeEvent = await JsonSerializer.DeserializeAsync(req.Body); + + if (bssEpisodeEvent == null) + { + return new BadRequestObjectResult("Deserialization resulted in null."); + } + + var validationResults = new List(); + var validationContext = new ValidationContext(bssEpisodeEvent); + + Validator.ValidateObject(bssEpisodeEvent, validationContext, true); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occured when reading request body"); + return new BadRequestObjectResult(ex.Message); + } + + try + { + var createPathwayEnrolment = new CreatePathwayParticipantDto + { + NhsNumber = bssEpisodeEvent.NhsNumber, + DOB = bssEpisodeEvent.DateOfBirth, + Name = $"{bssEpisodeEvent.FirstGivenName} {bssEpisodeEvent.FamilyName}", + ScreeningName = "Breast Screening", + PathwayTypeName = "Breast Screening Routine" + }; + + var cloudEvent = new CloudEvent( + "ServiceLayer", + "CreateBrestScreeningPathwayEnrolment", + createPathwayEnrolment + ); + + var response = await eventGridPublisherClient.SendEventAsync(cloudEvent); + + if (response.IsError) + { + logger.LogError( + "Failed to send event to Event Grid.\nSource: {source}\nType: {type}\n Response status code: {code}", + cloudEvent.Source, cloudEvent.Type, response.Status); + return new StatusCodeResult(500); + } + + return new OkResult(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send CreateBrestScreeningPathwayEnrolment event"); + return new StatusCodeResult(500); + } + } +} diff --git a/src/ServiceLayer.API/Models/BSSelectEpisodeEvent.cs b/src/ServiceLayer.API/Models/BSSelectEpisodeEvent.cs new file mode 100644 index 0000000..09cf95f --- /dev/null +++ b/src/ServiceLayer.API/Models/BSSelectEpisodeEvent.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace ServiceLayer.API.Models; + +public class BSSelectEpisodeEvent +{ + [JsonPropertyName("nhs_number")] + [RegularExpression(@"^\d{10}$", ErrorMessage = "nhs_number must be exactly 10 digits.")] + public required string NhsNumber { get; set; } + + [JsonPropertyName("date_of_birth")] + public required DateOnly DateOfBirth { get; set; } + + [JsonPropertyName("first_given_name")] + public required string FirstGivenName { get; set; } + + [JsonPropertyName("family_name")] + public required string FamilyName { get; set; } +} diff --git a/src/ServiceLayer.API/Program.cs b/src/ServiceLayer.API/Program.cs new file mode 100644 index 0000000..6bf7313 --- /dev/null +++ b/src/ServiceLayer.API/Program.cs @@ -0,0 +1,29 @@ +using Azure.Identity; +using Azure.Messaging.EventGrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var eventGridTopicUrl = Environment.GetEnvironmentVariable("EVENT_GRID_TOPIC_URL") + ?? throw new InvalidOperationException($"Environment variable 'EVENT_GRID_TOPIC_URL' is not set or is empty."); +var eventGridTopicKey = Environment.GetEnvironmentVariable("EVENT_GRID_TOPIC_KEY") + ?? throw new InvalidOperationException($"Environment variable 'EVENT_GRID_TOPIC_KEY' is not set or is empty."); + +var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .ConfigureServices((context, services) => + { + services.AddSingleton(sp => + { + var endpoint = new Uri(eventGridTopicUrl); + if (context.HostingEnvironment.IsDevelopment()) + { + var credentials = new Azure.AzureKeyCredential(eventGridTopicKey); + return new EventGridPublisherClient(endpoint, credentials); + } + + return new EventGridPublisherClient(endpoint, new ManagedIdentityCredential()); + }); + }) + .Build(); + +await host.RunAsync(); diff --git a/src/ServiceLayer.API/Properties/launchSettings.json b/src/ServiceLayer.API/Properties/launchSettings.json new file mode 100644 index 0000000..7dd579c --- /dev/null +++ b/src/ServiceLayer.API/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "ServiceLayer.API": { + "commandName": "Project", + "commandLineArgs": "--port 7065", + "launchBrowser": false + } + } +} \ No newline at end of file diff --git a/src/ServiceLayer.API/ServiceLayer.API.csproj b/src/ServiceLayer.API/ServiceLayer.API.csproj new file mode 100644 index 0000000..f7eb804 --- /dev/null +++ b/src/ServiceLayer.API/ServiceLayer.API.csproj @@ -0,0 +1,30 @@ + + + net9.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + diff --git a/src/ServiceLayer.API/Shared/CreatePathwayParticipantDto.cs b/src/ServiceLayer.API/Shared/CreatePathwayParticipantDto.cs new file mode 100644 index 0000000..f88d3ec --- /dev/null +++ b/src/ServiceLayer.API/Shared/CreatePathwayParticipantDto.cs @@ -0,0 +1,10 @@ +namespace ServiceLayer.API.Shared; + +public class CreatePathwayParticipantDto +{ + public required string PathwayTypeName { get; set; } + public required string ScreeningName { get; set; } + public DateOnly DOB { get; set; } + public required string NhsNumber { get; set; } + public required string Name { get; set; } +} diff --git a/src/ServiceLayer.API/host.json b/src/ServiceLayer.API/host.json new file mode 100644 index 0000000..bce4580 --- /dev/null +++ b/src/ServiceLayer.API/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + } +} diff --git a/src/ServiceLayer.sln b/src/ServiceLayer.sln new file mode 100644 index 0000000..6b12a50 --- /dev/null +++ b/src/ServiceLayer.sln @@ -0,0 +1,26 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceLayer.API", "ServiceLayer.API\ServiceLayer.API.csproj", "{B56B41FF-FA39-0FDE-E266-6EC09B268DFB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {EEE06B13-019F-4618-A6EB-FD834B6EA7D7} + EndGlobalSection +EndGlobal From 67bec6337f681cdd6d5830d89530d7a8a062a697 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Thu, 3 Apr 2025 15:35:43 +0100 Subject: [PATCH 02/20] test: Added unit tests for BSSelectFunctions --- .../Functions/BSSelectFunctions.cs | 22 +- .../Models/BSSelectEpisode.cs | 28 ++ .../Models/BSSelectEpisodeEvent.cs | 20 - .../Shared/CreatePathwayParticipantDto.cs | 3 +- src/ServiceLayer.sln | 30 ++ tests/.gitkeep | 0 .../Functions/BSSelectFunctionsTests.cs | 378 ++++++++++++++++++ .../ServiceLayer.API.Tests.csproj | 26 ++ .../Utils/SetupRequest.cs | 32 ++ 9 files changed, 508 insertions(+), 31 deletions(-) create mode 100644 src/ServiceLayer.API/Models/BSSelectEpisode.cs delete mode 100644 src/ServiceLayer.API/Models/BSSelectEpisodeEvent.cs delete mode 100644 tests/.gitkeep create mode 100644 tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs create mode 100644 tests/ServiceLayer.API.Tests/ServiceLayer.API.Tests.csproj create mode 100644 tests/ServiceLayer.API.Tests/Utils/SetupRequest.cs diff --git a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs index 207be0e..87bf70e 100644 --- a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs +++ b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs @@ -2,9 +2,9 @@ using System.Text.Json; using Azure.Messaging; using Azure.Messaging.EventGrid; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using ServiceLayer.API.Models; using ServiceLayer.API.Shared; @@ -14,17 +14,18 @@ namespace ServiceLayer.API.Functions; public class BSSelectFunctions(ILogger logger, EventGridPublisherClient eventGridPublisherClient) { [Function("CreateEpisodeEvent")] - public async Task CreateEpisodeEvent([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bsselect/episodes")] HttpRequest req) + public async Task CreateEpisodeEvent([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bsselect/episodes")] HttpRequestData req) { - BSSelectEpisodeEvent? bssEpisodeEvent; + BSSelectEpisode? bssEpisodeEvent; try { - bssEpisodeEvent = await JsonSerializer.DeserializeAsync(req.Body); + bssEpisodeEvent = await JsonSerializer.DeserializeAsync(req.Body); if (bssEpisodeEvent == null) { - return new BadRequestObjectResult("Deserialization resulted in null."); + logger.LogError("Deserialization returned null"); + return new BadRequestObjectResult("Deserialization returned null"); } var validationResults = new List(); @@ -42,16 +43,17 @@ public async Task CreateEpisodeEvent([HttpTrigger(AuthorizationLe { var createPathwayEnrolment = new CreatePathwayParticipantDto { - NhsNumber = bssEpisodeEvent.NhsNumber, - DOB = bssEpisodeEvent.DateOfBirth, - Name = $"{bssEpisodeEvent.FirstGivenName} {bssEpisodeEvent.FamilyName}", + PathwayTypeId = new Guid("11111111-1111-1111-1111-111111111113"), + PathwayTypeName = "Breast Screening Routine", ScreeningName = "Breast Screening", - PathwayTypeName = "Breast Screening Routine" + NhsNumber = bssEpisodeEvent.NhsNumber!, + DOB = (DateOnly)bssEpisodeEvent.DateOfBirth!, + Name = $"{bssEpisodeEvent.FirstGivenName} {bssEpisodeEvent.FamilyName}", }; var cloudEvent = new CloudEvent( "ServiceLayer", - "CreateBrestScreeningPathwayEnrolment", + "CreatePathwayEnrolment", createPathwayEnrolment ); diff --git a/src/ServiceLayer.API/Models/BSSelectEpisode.cs b/src/ServiceLayer.API/Models/BSSelectEpisode.cs new file mode 100644 index 0000000..e1c6e09 --- /dev/null +++ b/src/ServiceLayer.API/Models/BSSelectEpisode.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace ServiceLayer.API.Models; + +public class BSSelectEpisode +{ + [JsonPropertyName("episode_id")] + [Required(ErrorMessage = "The episode_id is required")] + public string? EpisodeId { get; set; } + + [JsonPropertyName("nhs_number")] + [Required(ErrorMessage = "The nhs_number is required")] + [RegularExpression(@"^\d{10}$", ErrorMessage = "The nhs_number must be exactly 10 digits")] + public string? NhsNumber { get; set; } + + [JsonPropertyName("date_of_birth")] + [Required(ErrorMessage = "The date_of_birth is required")] + public DateOnly? DateOfBirth { get; set; } + + [JsonPropertyName("first_given_name")] + [Required(ErrorMessage = "The first_given_name is required")] + public string? FirstGivenName { get; set; } + + [JsonPropertyName("family_name")] + [Required(ErrorMessage = "The family_name is required")] + public string? FamilyName { get; set; } +} diff --git a/src/ServiceLayer.API/Models/BSSelectEpisodeEvent.cs b/src/ServiceLayer.API/Models/BSSelectEpisodeEvent.cs deleted file mode 100644 index 09cf95f..0000000 --- a/src/ServiceLayer.API/Models/BSSelectEpisodeEvent.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; - -namespace ServiceLayer.API.Models; - -public class BSSelectEpisodeEvent -{ - [JsonPropertyName("nhs_number")] - [RegularExpression(@"^\d{10}$", ErrorMessage = "nhs_number must be exactly 10 digits.")] - public required string NhsNumber { get; set; } - - [JsonPropertyName("date_of_birth")] - public required DateOnly DateOfBirth { get; set; } - - [JsonPropertyName("first_given_name")] - public required string FirstGivenName { get; set; } - - [JsonPropertyName("family_name")] - public required string FamilyName { get; set; } -} diff --git a/src/ServiceLayer.API/Shared/CreatePathwayParticipantDto.cs b/src/ServiceLayer.API/Shared/CreatePathwayParticipantDto.cs index f88d3ec..cb630c2 100644 --- a/src/ServiceLayer.API/Shared/CreatePathwayParticipantDto.cs +++ b/src/ServiceLayer.API/Shared/CreatePathwayParticipantDto.cs @@ -2,9 +2,10 @@ namespace ServiceLayer.API.Shared; public class CreatePathwayParticipantDto { + public required Guid PathwayTypeId { get; set; } public required string PathwayTypeName { get; set; } public required string ScreeningName { get; set; } - public DateOnly DOB { get; set; } public required string NhsNumber { get; set; } + public required DateOnly DOB { get; set; } public required string Name { get; set; } } diff --git a/src/ServiceLayer.sln b/src/ServiceLayer.sln index 6b12a50..d5c785b 100644 --- a/src/ServiceLayer.sln +++ b/src/ServiceLayer.sln @@ -1,24 +1,54 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceLayer.API", "ServiceLayer.API\ServiceLayer.API.csproj", "{B56B41FF-FA39-0FDE-E266-6EC09B268DFB}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceLayer.API.Tests", "..\tests\ServiceLayer.API.Tests\ServiceLayer.API.Tests.csproj", "{BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x64.ActiveCfg = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x64.Build.0 = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x86.ActiveCfg = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x86.Build.0 = Debug|Any CPU {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|Any CPU.Build.0 = Release|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x64.ActiveCfg = Release|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x64.Build.0 = Release|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x86.ActiveCfg = Release|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x86.Build.0 = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x64.Build.0 = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x86.Build.0 = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|Any CPU.Build.0 = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x64.ActiveCfg = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x64.Build.0 = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x86.ActiveCfg = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EEE06B13-019F-4618-A6EB-FD834B6EA7D7} diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs new file mode 100644 index 0000000..096f7bf --- /dev/null +++ b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs @@ -0,0 +1,378 @@ +using Azure; +using Azure.Messaging; +using Azure.Messaging.EventGrid; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using ServiceLayer.API.Functions; +using ServiceLayer.API.Tests.Utils; + +namespace ServiceLayer.API.Tests.Functions; + +public class BSSelectFunctionsTests +{ + private readonly Mock> _logger = new(); + private readonly Mock _mockEventGridPublisherClient = new(); + private readonly BSSelectFunctions _functions; + private readonly SetupRequest _setupRequest = new(); + + public BSSelectFunctionsTests() + { + _functions = new BSSelectFunctions(_logger.Object, _mockEventGridPublisherClient.Object); + } + + [Fact] + public async Task CreateEpisodeEvent_ShouldSendEventAndReturnOk_WhenRequestIsValid() + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = "9990000000", + date_of_birth = "1970-01-01", + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + var mockResponse = Mock.Of(r => r.IsError == false); + _mockEventGridPublisherClient.Setup(x => x.SendEventAsync(It.IsAny(), It.IsAny())).ReturnsAsync(mockResponse); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + Assert.IsType(response); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), default), Times.Once()); + } + + [Fact] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequestBodyEmpty() + { + // Arrange + var request = _setupRequest.CreateMockHttpRequest(null); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("Deserialization returned null", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenEpisodeIdIsMissing() + { + // Arrange + var episode = new + { + nhs_number = "9990000000", + date_of_birth = "1970-01-01", + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The episode_id is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenEpisodeIdIsEmptyValue(string? episodeId) + { + // Arrange + var episode = new + { + episode_id = episodeId, + nhs_number = "9990000000", + date_of_birth = "1970-01-01", + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The episode_id is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsMissing() + { + // Arrange + var episode = new + { + episode_id = "123", + date_of_birth = "1970-01-01", + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The nhs_number is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsEmptyValue(string? nhsNumber) + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = nhsNumber, + date_of_birth = "1970-01-01", + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The nhs_number is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Theory] + [InlineData("ABCDEFGHIJ")] + [InlineData("999999999")] + [InlineData("10000000000")] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsInvalidValue(string? nhsNumber) + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = nhsNumber, + date_of_birth = "1970-01-01", + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The nhs_number must be exactly 10 digits", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsMissing() + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = "9990000000", + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The date_of_birth is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Theory] + [InlineData(null)] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsEmptyValue(string? dateOfBirth) + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = "9990000000", + date_of_birth = dateOfBirth, + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The date_of_birth is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFirstGivenNameIsMissing() + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = "9990000000", + date_of_birth = "1970-01-01", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The first_given_name is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFirstGivenNameIsEmptyValue(string? firstGivenName) + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = "9990000000", + date_of_birth = "1970-01-01", + first_given_name = firstGivenName, + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The first_given_name is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFamilyNameIsMissing() + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = "9990000000", + date_of_birth = "1970-01-01", + first_given_name = "Test" + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The family_name is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFamilyNameIsEmptyValue(string? familyName) + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = "9990000000", + date_of_birth = "1970-01-01", + first_given_name = "Test", + family_name = familyName, + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("The family_name is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task CreateEpisodeEvent_ShouldReturnInternalServerError_WhenEventFailsToSend() + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = "9990000000", + date_of_birth = "1970-01-01", + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + var mockResponse = Mock.Of(r => r.IsError == true); + _mockEventGridPublisherClient.Setup(x => x.SendEventAsync(It.IsAny(), It.IsAny())).ReturnsAsync(mockResponse); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), default), Times.Once()); + } + + [Fact] + public async Task CreateEpisodeEvent_ShouldReturnInternalServerError_WhenSendEventThrowsException() + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = "9990000000", + date_of_birth = "1970-01-01", + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + var mockResponse = Mock.Of(r => r.IsError == true); + _mockEventGridPublisherClient.Setup(x => x.SendEventAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new RequestFailedException("Failed to send event to Event Grid")); + + // Act + var response = await _functions.CreateEpisodeEvent(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), default), Times.Once()); + } +} diff --git a/tests/ServiceLayer.API.Tests/ServiceLayer.API.Tests.csproj b/tests/ServiceLayer.API.Tests/ServiceLayer.API.Tests.csproj new file mode 100644 index 0000000..4fe2a71 --- /dev/null +++ b/tests/ServiceLayer.API.Tests/ServiceLayer.API.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/ServiceLayer.API.Tests/Utils/SetupRequest.cs b/tests/ServiceLayer.API.Tests/Utils/SetupRequest.cs new file mode 100644 index 0000000..7c7f894 --- /dev/null +++ b/tests/ServiceLayer.API.Tests/Utils/SetupRequest.cs @@ -0,0 +1,32 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Moq; + +namespace ServiceLayer.API.Tests.Utils; + +public class SetupRequest +{ + private readonly Mock _context; + + public SetupRequest() + { + _context = new Mock(); + } + + /// + /// Creates a mock HTTP request with a JSON body + /// + /// The object to serialize as JSON + /// A mock HttpRequestData + public HttpRequestData CreateMockHttpRequest(object? body) + { + var json = JsonSerializer.Serialize(body); + var byteArray = Encoding.UTF8.GetBytes(json); + var memoryStream = new MemoryStream(byteArray); + var mockRequest = new Mock(MockBehavior.Strict, _context.Object); + mockRequest.Setup(r => r.Body).Returns(memoryStream); + return mockRequest.Object; + } +} From 4816b4fa71d607fb7ba6e53251da712880d8a7d4 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Fri, 4 Apr 2025 11:15:41 +0100 Subject: [PATCH 03/20] refactor: Renamed method/function and modified error messages --- .../Functions/BSSelectFunctions.cs | 8 +-- .../Models/BSSelectEpisode.cs | 12 ++--- .../Functions/BSSelectFunctionsTests.cs | 52 +++++++++---------- 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs index 87bf70e..35e9d70 100644 --- a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs +++ b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs @@ -13,8 +13,8 @@ namespace ServiceLayer.API.Functions; public class BSSelectFunctions(ILogger logger, EventGridPublisherClient eventGridPublisherClient) { - [Function("CreateEpisodeEvent")] - public async Task CreateEpisodeEvent([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bsselect/episodes")] HttpRequestData req) + [Function("BSSelectIngressEpisode")] + public async Task IngressEpisode([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bsselect/episodes/ingress")] HttpRequestData req) { BSSelectEpisode? bssEpisodeEvent; @@ -53,7 +53,7 @@ public async Task CreateEpisodeEvent([HttpTrigger(AuthorizationLe var cloudEvent = new CloudEvent( "ServiceLayer", - "CreatePathwayEnrolment", + "EpisodeEvent", createPathwayEnrolment ); @@ -71,7 +71,7 @@ public async Task CreateEpisodeEvent([HttpTrigger(AuthorizationLe } catch (Exception ex) { - logger.LogError(ex, "Failed to send CreateBrestScreeningPathwayEnrolment event"); + logger.LogError(ex, "Failed to send event to Event Grid"); return new StatusCodeResult(500); } } diff --git a/src/ServiceLayer.API/Models/BSSelectEpisode.cs b/src/ServiceLayer.API/Models/BSSelectEpisode.cs index e1c6e09..c8362ff 100644 --- a/src/ServiceLayer.API/Models/BSSelectEpisode.cs +++ b/src/ServiceLayer.API/Models/BSSelectEpisode.cs @@ -6,23 +6,23 @@ namespace ServiceLayer.API.Models; public class BSSelectEpisode { [JsonPropertyName("episode_id")] - [Required(ErrorMessage = "The episode_id is required")] + [Required(ErrorMessage = "episode_id is required")] public string? EpisodeId { get; set; } [JsonPropertyName("nhs_number")] - [Required(ErrorMessage = "The nhs_number is required")] - [RegularExpression(@"^\d{10}$", ErrorMessage = "The nhs_number must be exactly 10 digits")] + [Required(ErrorMessage = "nhs_number is required")] + [RegularExpression(@"^\d{10}$", ErrorMessage = "nhs_number must be exactly 10 digits")] public string? NhsNumber { get; set; } [JsonPropertyName("date_of_birth")] - [Required(ErrorMessage = "The date_of_birth is required")] + [Required(ErrorMessage = "date_of_birth is required")] public DateOnly? DateOfBirth { get; set; } [JsonPropertyName("first_given_name")] - [Required(ErrorMessage = "The first_given_name is required")] + [Required(ErrorMessage = "first_given_name is required")] public string? FirstGivenName { get; set; } [JsonPropertyName("family_name")] - [Required(ErrorMessage = "The family_name is required")] + [Required(ErrorMessage = "family_name is required")] public string? FamilyName { get; set; } } diff --git a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs index 096f7bf..e408a96 100644 --- a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs +++ b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs @@ -39,7 +39,7 @@ public async Task CreateEpisodeEvent_ShouldSendEventAndReturnOk_WhenRequestIsVal _mockEventGridPublisherClient.Setup(x => x.SendEventAsync(It.IsAny(), It.IsAny())).ReturnsAsync(mockResponse); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert Assert.IsType(response); @@ -53,7 +53,7 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequestBodyEmpty var request = _setupRequest.CreateMockHttpRequest(null); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); @@ -75,11 +75,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenEpisodeIdIsMissi var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The episode_id is required", result.Value); + Assert.Equal("episode_id is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -101,11 +101,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenEpisodeIdIsEmpty var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The episode_id is required", result.Value); + Assert.Equal("episode_id is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -123,11 +123,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsMissi var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The nhs_number is required", result.Value); + Assert.Equal("nhs_number is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -149,11 +149,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsEmpty var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The nhs_number is required", result.Value); + Assert.Equal("nhs_number is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -175,11 +175,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsInval var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The nhs_number must be exactly 10 digits", result.Value); + Assert.Equal("nhs_number must be exactly 10 digits", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -197,11 +197,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsMis var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The date_of_birth is required", result.Value); + Assert.Equal("date_of_birth is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -221,11 +221,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsEmp var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The date_of_birth is required", result.Value); + Assert.Equal("date_of_birth is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -243,11 +243,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFirstGivenNameIs var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The first_given_name is required", result.Value); + Assert.Equal("first_given_name is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -269,11 +269,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFirstGivenNameIs var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The first_given_name is required", result.Value); + Assert.Equal("first_given_name is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -291,11 +291,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFamilyNameIsMiss var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The family_name is required", result.Value); + Assert.Equal("family_name is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -317,11 +317,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFamilyNameIsEmpt var request = _setupRequest.CreateMockHttpRequest(episode); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("The family_name is required", result.Value); + Assert.Equal("family_name is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -342,7 +342,7 @@ public async Task CreateEpisodeEvent_ShouldReturnInternalServerError_WhenEventFa _mockEventGridPublisherClient.Setup(x => x.SendEventAsync(It.IsAny(), It.IsAny())).ReturnsAsync(mockResponse); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); @@ -368,7 +368,7 @@ public async Task CreateEpisodeEvent_ShouldReturnInternalServerError_WhenSendEve .ThrowsAsync(new RequestFailedException("Failed to send event to Event Grid")); // Act - var response = await _functions.CreateEpisodeEvent(request); + var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); From 79943740102b28550cb1a48709b2e6085ab69159 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Fri, 4 Apr 2025 11:16:28 +0100 Subject: [PATCH 04/20] docs: Added OpenAPI spec and API request collection --- .../servicelayer-api-2025-04-04.har | 59 ++++++++++++++++ api/openapi/openapi.yaml | 68 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 api/api-request-collection/servicelayer-api-2025-04-04.har create mode 100644 api/openapi/openapi.yaml diff --git a/api/api-request-collection/servicelayer-api-2025-04-04.har b/api/api-request-collection/servicelayer-api-2025-04-04.har new file mode 100644 index 0000000..cf6213a --- /dev/null +++ b/api/api-request-collection/servicelayer-api-2025-04-04.har @@ -0,0 +1,59 @@ +{ + "log": { + "version": "1.2", + "creator": { + "name": "Insomnia REST Client", + "version": "insomnia.desktop.app:v11.0.1" + }, + "entries": [ + { + "startedDateTime": "2025-04-04T09:29:36.496Z", + "time": 0, + "request": { + "method": "POST", + "url": "http://localhost:7001/api/bsselect/episodes/ingress", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/json", + "text": "{\n\t\"episode_id\": \"123\",\n\t\"nhs_number\": \"9990000000\",\n\t\"date_of_birth\": \"1970-01-01\",\n\t\"first_given_name\": \"Test\",\n\t\"family_name\": \"User\"\n}" + }, + "headersSize": -1, + "bodySize": -1 + }, + "response": { + "status": 0, + "statusText": "", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [], + "content": { + "size": 0, + "mimeType": "" + }, + "redirectURL": "", + "headersSize": -1, + "bodySize": -1 + }, + "cache": {}, + "timings": { + "blocked": -1, + "dns": -1, + "connect": -1, + "send": 0, + "wait": 0, + "receive": 0, + "ssl": -1 + }, + "comment": "BS Select Episode Ingress" + } + ] + } +} diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml new file mode 100644 index 0000000..89d78f0 --- /dev/null +++ b/api/openapi/openapi.yaml @@ -0,0 +1,68 @@ +openapi: 3.0.3 +info: + title: ServiceLayer API + version: 1.0.0 + description: API used to ingest episodes from screening services into NSP + +paths: + + /bsselect/episodes/ingress: + post: + summary: BS Select Episode Ingress + description: Validates the incoming BS Select episode and enqueues it for further processing within the NSP + operationId: BS Select Episode Ingress + tags: + - Episodes + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BSSelectEpisode" + responses: + '200': + description: Episode accepted + content: + application/json: + schema: + $ref: "#/components/schemas/BSSelectEpisode" + '400': + description: Bad request. Supplied episode payload invalid. + content: + text/plain: + schema: + type: string + example: "nhs_number is required" + '500': + description: Internal server error. This indicates an unexpected failure in the service. + +components: + schemas: + BSSelectEpisode: + type: object + required: + - episode_id + - nhs_number + - date_of_birth + - first_given_name + - family_name + properties: + episode_id: + type: string + description: Unique identifier for the Episode + nhs_number: + type: string + pattern: '^\d{10}$' + description: NHS Number (exactly 10 digits) + date_of_birth: + type: string + format: date + description: Date of birth of the Participant + first_given_name: + type: string + maxLength: 100 + description: First name of the Participant + family_name: + type: string + maxLength: 100 + description: Surname of the Participant From 512998172d225e4fce2f5e961913404bd83bec67 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Fri, 4 Apr 2025 11:28:10 +0100 Subject: [PATCH 05/20] docs: Updated readme --- README.md | 14 ++++++++++++++ api/openapi/openapi.yaml | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 05445a8..5ef2c3f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Service Layer - [Configuration](#configuration) - [Usage](#usage) - [Testing](#testing) + - [OpenAPI Specifications](#openapi-specifications) + - [API Request Collection](#api-request-collection) - [Contacts](#contacts) - [Licence](#licence) @@ -64,6 +66,18 @@ The full test suite can be ran with `make test`. Unit tests can be ran with `make test-unit` +## OpenAPI Specifications + +The following OpenAPI Specification exist for Service Layer: + +- Service Layer API - [Raw](https://raw.githubusercontent.com/NHSDigital/dtos-service-layer/refs/heads/main/api/openapi/openapi.yaml) / [Swagger Editor](https://editor.swagger.io/?url=https://raw.githubusercontent.com/NHSDigital/dtos-service-layer/refs/heads/main/api/openapi/openapi.yaml) + +## API Request Collection + +An API request collection for Service Layer exists in HAR format. It can be imported into Postman or Insomnia. + +- [Collection folder](api/api-request-collection) + ## Contacts If you are on the NHS England Slack you can contact the team on #mays-team, otherwise you can open a GitHub issue. diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 89d78f0..1037dd3 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - title: ServiceLayer API + title: Service Layer API version: 1.0.0 description: API used to ingest episodes from screening services into NSP From 08a85b9b4055c808569f76bd5cdc80f1e8f6c984 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Fri, 4 Apr 2025 12:40:50 +0100 Subject: [PATCH 06/20] chore: Fix file formatting issues and made port number consistent --- .../Properties/launchSettings.json | 18 ++-- src/ServiceLayer.sln | 86 +++++++++---------- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/src/ServiceLayer.API/Properties/launchSettings.json b/src/ServiceLayer.API/Properties/launchSettings.json index 7dd579c..2223411 100644 --- a/src/ServiceLayer.API/Properties/launchSettings.json +++ b/src/ServiceLayer.API/Properties/launchSettings.json @@ -1,9 +1,9 @@ -{ - "profiles": { - "ServiceLayer.API": { - "commandName": "Project", - "commandLineArgs": "--port 7065", - "launchBrowser": false - } - } -} \ No newline at end of file +{ + "profiles": { + "ServiceLayer.API": { + "commandName": "Project", + "commandLineArgs": "--port 7001", + "launchBrowser": false + } + } +} diff --git a/src/ServiceLayer.sln b/src/ServiceLayer.sln index d5c785b..0e4aad3 100644 --- a/src/ServiceLayer.sln +++ b/src/ServiceLayer.sln @@ -10,47 +10,47 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceLayer.API.Tests", "..\tests\ServiceLayer.API.Tests\ServiceLayer.API.Tests.csproj", "{BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}" EndProject Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x64.ActiveCfg = Debug|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x64.Build.0 = Debug|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x86.ActiveCfg = Debug|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x86.Build.0 = Debug|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|Any CPU.Build.0 = Release|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x64.ActiveCfg = Release|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x64.Build.0 = Release|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x86.ActiveCfg = Release|Any CPU - {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x86.Build.0 = Release|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x64.ActiveCfg = Debug|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x64.Build.0 = Debug|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x86.ActiveCfg = Debug|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x86.Build.0 = Debug|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|Any CPU.Build.0 = Release|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x64.ActiveCfg = Release|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x64.Build.0 = Release|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x86.ActiveCfg = Release|Any CPU - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72} = {0AB3BF05-4346-4AA6-1389-037BE0695223} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {EEE06B13-019F-4618-A6EB-FD834B6EA7D7} - EndGlobalSection + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x64.ActiveCfg = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x64.Build.0 = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x86.ActiveCfg = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Debug|x86.Build.0 = Debug|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|Any CPU.Build.0 = Release|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x64.ActiveCfg = Release|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x64.Build.0 = Release|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x86.ActiveCfg = Release|Any CPU + {B56B41FF-FA39-0FDE-E266-6EC09B268DFB}.Release|x86.Build.0 = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x64.Build.0 = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Debug|x86.Build.0 = Debug|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|Any CPU.Build.0 = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x64.ActiveCfg = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x64.Build.0 = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x86.ActiveCfg = Release|Any CPU + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {EEE06B13-019F-4618-A6EB-FD834B6EA7D7} + EndGlobalSection EndGlobal From bcf190f024842cb4fe03f4933be52b0036544a89 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Fri, 4 Apr 2025 12:42:48 +0100 Subject: [PATCH 07/20] style: Fixed file formatting issue --- .../servicelayer-api-2025-04-04.har | 114 +++++++++--------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/api/api-request-collection/servicelayer-api-2025-04-04.har b/api/api-request-collection/servicelayer-api-2025-04-04.har index cf6213a..19750bd 100644 --- a/api/api-request-collection/servicelayer-api-2025-04-04.har +++ b/api/api-request-collection/servicelayer-api-2025-04-04.har @@ -1,59 +1,59 @@ { - "log": { - "version": "1.2", - "creator": { - "name": "Insomnia REST Client", - "version": "insomnia.desktop.app:v11.0.1" - }, - "entries": [ - { - "startedDateTime": "2025-04-04T09:29:36.496Z", - "time": 0, - "request": { - "method": "POST", - "url": "http://localhost:7001/api/bsselect/episodes/ingress", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [ - { - "name": "Content-Type", - "value": "application/json" - } - ], - "queryString": [], - "postData": { - "mimeType": "application/json", - "text": "{\n\t\"episode_id\": \"123\",\n\t\"nhs_number\": \"9990000000\",\n\t\"date_of_birth\": \"1970-01-01\",\n\t\"first_given_name\": \"Test\",\n\t\"family_name\": \"User\"\n}" - }, - "headersSize": -1, - "bodySize": -1 - }, - "response": { - "status": 0, - "statusText": "", - "httpVersion": "HTTP/1.1", - "cookies": [], - "headers": [], - "content": { - "size": 0, - "mimeType": "" - }, - "redirectURL": "", - "headersSize": -1, - "bodySize": -1 - }, - "cache": {}, - "timings": { - "blocked": -1, - "dns": -1, - "connect": -1, - "send": 0, - "wait": 0, - "receive": 0, - "ssl": -1 - }, - "comment": "BS Select Episode Ingress" - } - ] - } + "log": { + "version": "1.2", + "creator": { + "name": "Insomnia REST Client", + "version": "insomnia.desktop.app:v11.0.1" + }, + "entries": [ + { + "startedDateTime": "2025-04-04T09:29:36.496Z", + "time": 0, + "request": { + "method": "POST", + "url": "http://localhost:7001/api/bsselect/episodes/ingress", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/json", + "text": "{\n\t\"episode_id\": \"123\",\n\t\"nhs_number\": \"9990000000\",\n\t\"date_of_birth\": \"1970-01-01\",\n\t\"first_given_name\": \"Test\",\n\t\"family_name\": \"User\"\n}" + }, + "headersSize": -1, + "bodySize": -1 + }, + "response": { + "status": 0, + "statusText": "", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [], + "content": { + "size": 0, + "mimeType": "" + }, + "redirectURL": "", + "headersSize": -1, + "bodySize": -1 + }, + "cache": {}, + "timings": { + "blocked": -1, + "dns": -1, + "connect": -1, + "send": 0, + "wait": 0, + "receive": 0, + "ssl": -1 + }, + "comment": "BS Select Episode Ingress" + } + ] + } } From 93003930d1204b07809e51b8314860ae115b8151 Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Fri, 4 Apr 2025 15:56:43 +0100 Subject: [PATCH 08/20] fix solution name --- .github/actions/perform-static-analysis/action.yaml | 2 +- gitleaks-report.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 gitleaks-report.json diff --git a/.github/actions/perform-static-analysis/action.yaml b/.github/actions/perform-static-analysis/action.yaml index eca1719..07e999e 100644 --- a/.github/actions/perform-static-analysis/action.yaml +++ b/.github/actions/perform-static-analysis/action.yaml @@ -53,7 +53,7 @@ runs: echo "${{ inputs.sonar_token }}" ./.sonar/scanner/dotnet-sonarscanner begin /k:"${{ inputs.sonar_project_key }}" /o:"${{ inputs.sonar_organisation_key }}" /d:sonar.token="${{ inputs.sonar_token }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths="coverage.xml" /d:sonar.typescript.lcov.reportsPaths="src/web/coverage/lcov.info" /d:sonar.lang.patterns.ts=**/*.ts,**/*.tsx,**/*.cts,**/*.mts /d:sonar.lang.patterns.js=**/*.js,**/*.jsx,**/*.cjs,**/*.mjs,**/*.vue /d:sonar.javascript.enabled=false dotnet build src/api/ParticipantManager.API.sln - ./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/api/ServiceLayer.API.sln + ./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/ServiceLayer.API.sln cd src/web npm ci npm run test:unit:coverage -- --coverageDirectory=coverage --coverageReporters=lcov diff --git a/gitleaks-report.json b/gitleaks-report.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/gitleaks-report.json @@ -0,0 +1 @@ +[] From 895678755be13a11c27cb09456a857242d7bea4e Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Fri, 4 Apr 2025 16:00:34 +0100 Subject: [PATCH 09/20] fix solution name again --- .github/actions/perform-static-analysis/action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/perform-static-analysis/action.yaml b/.github/actions/perform-static-analysis/action.yaml index 07e999e..0774878 100644 --- a/.github/actions/perform-static-analysis/action.yaml +++ b/.github/actions/perform-static-analysis/action.yaml @@ -52,7 +52,7 @@ runs: echo "${{ inputs.sonar_organisation_key }}" echo "${{ inputs.sonar_token }}" ./.sonar/scanner/dotnet-sonarscanner begin /k:"${{ inputs.sonar_project_key }}" /o:"${{ inputs.sonar_organisation_key }}" /d:sonar.token="${{ inputs.sonar_token }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths="coverage.xml" /d:sonar.typescript.lcov.reportsPaths="src/web/coverage/lcov.info" /d:sonar.lang.patterns.ts=**/*.ts,**/*.tsx,**/*.cts,**/*.mts /d:sonar.lang.patterns.js=**/*.js,**/*.jsx,**/*.cjs,**/*.mjs,**/*.vue /d:sonar.javascript.enabled=false - dotnet build src/api/ParticipantManager.API.sln + dotnet build src/ServiceLayer.API.sln ./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/ServiceLayer.API.sln cd src/web npm ci From 714b1969c4dfc10cb9e3ed57c012ec61256f2610 Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Fri, 4 Apr 2025 16:13:52 +0100 Subject: [PATCH 10/20] fix solution file name --- .github/actions/perform-static-analysis/action.yaml | 4 ++-- scripts/config/sonar-scanner.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/perform-static-analysis/action.yaml b/.github/actions/perform-static-analysis/action.yaml index 0774878..d7d0ec2 100644 --- a/.github/actions/perform-static-analysis/action.yaml +++ b/.github/actions/perform-static-analysis/action.yaml @@ -52,8 +52,8 @@ runs: echo "${{ inputs.sonar_organisation_key }}" echo "${{ inputs.sonar_token }}" ./.sonar/scanner/dotnet-sonarscanner begin /k:"${{ inputs.sonar_project_key }}" /o:"${{ inputs.sonar_organisation_key }}" /d:sonar.token="${{ inputs.sonar_token }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths="coverage.xml" /d:sonar.typescript.lcov.reportsPaths="src/web/coverage/lcov.info" /d:sonar.lang.patterns.ts=**/*.ts,**/*.tsx,**/*.cts,**/*.mts /d:sonar.lang.patterns.js=**/*.js,**/*.jsx,**/*.cjs,**/*.mjs,**/*.vue /d:sonar.javascript.enabled=false - dotnet build src/ServiceLayer.API.sln - ./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/ServiceLayer.API.sln + dotnet build src/ServiceLayer.sln + ./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/ServiceLayer.sln cd src/web npm ci npm run test:unit:coverage -- --coverageDirectory=coverage --coverageReporters=lcov diff --git a/scripts/config/sonar-scanner.properties b/scripts/config/sonar-scanner.properties index 9577916..f23aa44 100644 --- a/scripts/config/sonar-scanner.properties +++ b/scripts/config/sonar-scanner.properties @@ -41,5 +41,5 @@ sonar.typescript.file.suffixes=.ts,.tsx sonar.dotnet.key=SonarAnalyzer.CSharp # Run C# Scanner (Requires Build Before Analysis) -sonar.dotnet.visualstudio.solution.file=ServiceLayer.API.sln +sonar.dotnet.visualstudio.solution.file=ServiceLayer.sln sonar.dotnet.build=false # Set to true if you want Sonar to build before scanning From 97dd6adb4ec15073827dda9118a3ea02cbe1b2cd Mon Sep 17 00:00:00 2001 From: Ian Nelson Date: Fri, 4 Apr 2025 16:18:03 +0100 Subject: [PATCH 11/20] no web to be analysed --- .github/actions/perform-static-analysis/action.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/actions/perform-static-analysis/action.yaml b/.github/actions/perform-static-analysis/action.yaml index d7d0ec2..86a8a20 100644 --- a/.github/actions/perform-static-analysis/action.yaml +++ b/.github/actions/perform-static-analysis/action.yaml @@ -54,9 +54,4 @@ runs: ./.sonar/scanner/dotnet-sonarscanner begin /k:"${{ inputs.sonar_project_key }}" /o:"${{ inputs.sonar_organisation_key }}" /d:sonar.token="${{ inputs.sonar_token }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths="coverage.xml" /d:sonar.typescript.lcov.reportsPaths="src/web/coverage/lcov.info" /d:sonar.lang.patterns.ts=**/*.ts,**/*.tsx,**/*.cts,**/*.mts /d:sonar.lang.patterns.js=**/*.js,**/*.jsx,**/*.cjs,**/*.mjs,**/*.vue /d:sonar.javascript.enabled=false dotnet build src/ServiceLayer.sln ./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/ServiceLayer.sln - cd src/web - npm ci - npm run test:unit:coverage -- --coverageDirectory=coverage --coverageReporters=lcov - sed -i 's|^SF:|SF:src/web/|g' coverage/lcov.info - cd ../.. ./.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="${{ inputs.sonar_token }}" From 5fec2b3373885a820b393fa6d5658e6ed69892aa Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Fri, 4 Apr 2025 16:52:44 +0100 Subject: [PATCH 12/20] refactor: Removed unused variable --- src/ServiceLayer.API/Functions/BSSelectFunctions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs index 35e9d70..fcf6481 100644 --- a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs +++ b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs @@ -28,7 +28,6 @@ public async Task IngressEpisode([HttpTrigger(AuthorizationLevel. return new BadRequestObjectResult("Deserialization returned null"); } - var validationResults = new List(); var validationContext = new ValidationContext(bssEpisodeEvent); Validator.ValidateObject(bssEpisodeEvent, validationContext, true); From 48e068c80b56d5e6552905541aaa12c4e863938a Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Mon, 7 Apr 2025 09:11:01 +0100 Subject: [PATCH 13/20] feat: Introduced validation attribute for DateOnly to have better control over error messages --- .../Functions/BSSelectFunctions.cs | 2 +- .../Models/BSSelectEpisode.cs | 4 ++- .../Shared/ValidDateOnlyAttribute.cs | 16 +++++++++++ .../Functions/BSSelectFunctionsTests.cs | 27 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs diff --git a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs index fcf6481..2c25908 100644 --- a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs +++ b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs @@ -46,7 +46,7 @@ public async Task IngressEpisode([HttpTrigger(AuthorizationLevel. PathwayTypeName = "Breast Screening Routine", ScreeningName = "Breast Screening", NhsNumber = bssEpisodeEvent.NhsNumber!, - DOB = (DateOnly)bssEpisodeEvent.DateOfBirth!, + DOB = DateOnly.Parse(bssEpisodeEvent.DateOfBirth!), Name = $"{bssEpisodeEvent.FirstGivenName} {bssEpisodeEvent.FamilyName}", }; diff --git a/src/ServiceLayer.API/Models/BSSelectEpisode.cs b/src/ServiceLayer.API/Models/BSSelectEpisode.cs index c8362ff..7fdf99b 100644 --- a/src/ServiceLayer.API/Models/BSSelectEpisode.cs +++ b/src/ServiceLayer.API/Models/BSSelectEpisode.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; +using ServiceLayer.API.Shared; namespace ServiceLayer.API.Models; @@ -16,7 +17,8 @@ public class BSSelectEpisode [JsonPropertyName("date_of_birth")] [Required(ErrorMessage = "date_of_birth is required")] - public DateOnly? DateOfBirth { get; set; } + [ValidDateOnly(ErrorMessage = "date_of_birth is invalid")] + public string? DateOfBirth { get; set; } [JsonPropertyName("first_given_name")] [Required(ErrorMessage = "first_given_name is required")] diff --git a/src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs b/src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs new file mode 100644 index 0000000..e44d317 --- /dev/null +++ b/src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace ServiceLayer.API.Shared; + +public class ValidDateOnlyAttribute : ValidationAttribute +{ + public override bool IsValid(object? value) + { + if (value is string s && DateOnly.TryParse(s, out _)) + { + return true; + } + + return false; + } +} diff --git a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs index e408a96..90b5270 100644 --- a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs +++ b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs @@ -207,6 +207,8 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsMis [Theory] [InlineData(null)] + [InlineData("")] + [InlineData(" ")] public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsEmptyValue(string? dateOfBirth) { // Arrange @@ -229,6 +231,31 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsEmp _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } + [Theory] + [InlineData("ABC")] + [InlineData("123")] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsInvalidValue(string? dateOfBirth) + { + // Arrange + var episode = new + { + episode_id = "123", + nhs_number = "9990000000", + date_of_birth = dateOfBirth, + first_given_name = "Test", + family_name = "User", + }; + var request = _setupRequest.CreateMockHttpRequest(episode); + + // Act + var response = await _functions.IngressEpisode(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("date_of_birth is invalid", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + [Fact] public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFirstGivenNameIsMissing() { From 3595704f3fb401f3c109df7d2a3c43dda9cd95a7 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Mon, 7 Apr 2025 09:58:30 +0100 Subject: [PATCH 14/20] refactor: specify CultureInfo when parsing DateOnly and added AttributeUsage to ValidDateOnlyAttribute --- src/ServiceLayer.API/Functions/BSSelectFunctions.cs | 3 ++- src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs index 2c25908..25f4ca7 100644 --- a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs +++ b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.Text.Json; using Azure.Messaging; using Azure.Messaging.EventGrid; @@ -46,7 +47,7 @@ public async Task IngressEpisode([HttpTrigger(AuthorizationLevel. PathwayTypeName = "Breast Screening Routine", ScreeningName = "Breast Screening", NhsNumber = bssEpisodeEvent.NhsNumber!, - DOB = DateOnly.Parse(bssEpisodeEvent.DateOfBirth!), + DOB = DateOnly.Parse(bssEpisodeEvent.DateOfBirth!, CultureInfo.CurrentCulture), Name = $"{bssEpisodeEvent.FirstGivenName} {bssEpisodeEvent.FamilyName}", }; diff --git a/src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs b/src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs index e44d317..58c34e2 100644 --- a/src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs +++ b/src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs @@ -1,12 +1,14 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; namespace ServiceLayer.API.Shared; +[AttributeUsage(AttributeTargets.Property)] public class ValidDateOnlyAttribute : ValidationAttribute { public override bool IsValid(object? value) { - if (value is string s && DateOnly.TryParse(s, out _)) + if (value is string s && DateOnly.TryParse(s, CultureInfo.CurrentCulture, out _)) { return true; } From 7f7d0facae9dccb7c4dee25de8287f87d8105e19 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Mon, 7 Apr 2025 11:05:25 +0100 Subject: [PATCH 15/20] ci: Added Dockerfile --- compose.yaml | 5 ++--- src/ServiceLayer.API/Dockerfile | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 src/ServiceLayer.API/Dockerfile diff --git a/compose.yaml b/compose.yaml index b786b75..48fedf2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,9 +1,9 @@ services: - ### ✅ API Backend (Azure Functions in .NET 9) ### + ### ✅ API (Azure Functions in .NET 9) ### api: container_name: "api" build: - context: ./src/api + context: ./src dockerfile: ServiceLayer.API/Dockerfile platform: linux/amd64 restart: always @@ -16,7 +16,6 @@ services: networks: - backend - networks: backend: driver: bridge diff --git a/src/ServiceLayer.API/Dockerfile b/src/ServiceLayer.API/Dockerfile new file mode 100644 index 0000000..ddf693f --- /dev/null +++ b/src/ServiceLayer.API/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS installer-env +WORKDIR /src/dotnet-function-app + +COPY ./ServiceLayer.API/ServiceLayer.API.csproj . +RUN dotnet restore + +COPY ./ServiceLayer.API/ . + +RUN dotnet publish -c Release -o /home/site/wwwroot + +FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated9.0 AS production +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true \ + ASPNETCORE_ENVIRONMENT=Production + +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser +USER appuser + +COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"] From 67b9fd06b88eb5d5d0ef8400f67700fb84832700 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Mon, 7 Apr 2025 15:03:45 +0100 Subject: [PATCH 16/20] refactor: added gitleaks-report.json to ignore file and switched log statement placeholders to PascalCase --- .gitignore | 3 +++ gitleaks-report.json | 1 - src/ServiceLayer.API/Functions/BSSelectFunctions.cs | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) delete mode 100644 gitleaks-report.json diff --git a/.gitignore b/.gitignore index fd827e2..3de328c 100644 --- a/.gitignore +++ b/.gitignore @@ -285,3 +285,6 @@ __pycache__/ __azurite*.json __blobstorage__ __queuestorage__ + +# Gitleaks report +gitleaks-report.json diff --git a/gitleaks-report.json b/gitleaks-report.json deleted file mode 100644 index fe51488..0000000 --- a/gitleaks-report.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs index 25f4ca7..f42acb5 100644 --- a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs +++ b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs @@ -62,7 +62,7 @@ public async Task IngressEpisode([HttpTrigger(AuthorizationLevel. if (response.IsError) { logger.LogError( - "Failed to send event to Event Grid.\nSource: {source}\nType: {type}\n Response status code: {code}", + "Failed to send event to Event Grid.\nSource: {Source}\nType: {Type}\n Response status code: {Status}", cloudEvent.Source, cloudEvent.Type, response.Status); return new StatusCodeResult(500); } From f9f0e5075038d0e55a78f88f14ab726ab7567952 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Mon, 7 Apr 2025 17:12:39 +0100 Subject: [PATCH 17/20] tests: Refactored tests to improve maintainability --- .../Functions/BSSelectFunctionsTests.cs | 265 +++--------------- 1 file changed, 43 insertions(+), 222 deletions(-) diff --git a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs index 90b5270..469b500 100644 --- a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs +++ b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs @@ -1,4 +1,5 @@ -using Azure; +using System.Dynamic; +using Azure; using Azure.Messaging; using Azure.Messaging.EventGrid; using Microsoft.AspNetCore.Http; @@ -16,25 +17,33 @@ public class BSSelectFunctionsTests private readonly Mock _mockEventGridPublisherClient = new(); private readonly BSSelectFunctions _functions; private readonly SetupRequest _setupRequest = new(); + private readonly dynamic _episode = new ExpandoObject(); + public static TheoryData RequiredPropertyNames => + [ + "episode_id", + "nhs_number", + "date_of_birth", + "first_given_name", + "family_name" + ]; public BSSelectFunctionsTests() { _functions = new BSSelectFunctions(_logger.Object, _mockEventGridPublisherClient.Object); + + // Configuring a valid episode + _episode.episode_id = "123"; + _episode.nhs_number = "9990000000"; + _episode.date_of_birth = "1970-01-01"; + _episode.first_given_name = "Test"; + _episode.family_name = "User"; } [Fact] public async Task CreateEpisodeEvent_ShouldSendEventAndReturnOk_WhenRequestIsValid() { // Arrange - var episode = new - { - episode_id = "123", - nhs_number = "9990000000", - date_of_birth = "1970-01-01", - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); + var request = _setupRequest.CreateMockHttpRequest(_episode); var mockResponse = Mock.Of(r => r.IsError == false); _mockEventGridPublisherClient.Setup(x => x.SendEventAsync(It.IsAny(), It.IsAny())).ReturnsAsync(mockResponse); @@ -61,99 +70,71 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequestBodyEmpty _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } - [Fact] - public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenEpisodeIdIsMissing() + [Theory] + [MemberData(nameof(RequiredPropertyNames))] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequiredPropertyIsMissing(string propertyName) { // Arrange - var episode = new - { - nhs_number = "9990000000", - date_of_birth = "1970-01-01", - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); + ((IDictionary)_episode).Remove(propertyName); + var request = _setupRequest.CreateMockHttpRequest(_episode); // Act var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("episode_id is required", result.Value); + Assert.Equal($"{propertyName} is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenEpisodeIdIsEmptyValue(string? episodeId) + [MemberData(nameof(RequiredPropertyNames))] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequiredPropertyIsNull(string propertyName) { // Arrange - var episode = new - { - episode_id = episodeId, - nhs_number = "9990000000", - date_of_birth = "1970-01-01", - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); + ((IDictionary)_episode)[propertyName] = null; + var request = _setupRequest.CreateMockHttpRequest(_episode); // Act var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("episode_id is required", result.Value); + Assert.Equal($"{propertyName} is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } - [Fact] - public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsMissing() + [Theory] + [MemberData(nameof(RequiredPropertyNames))] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequiredPropertyIsEmptyString(string propertyName) { // Arrange - var episode = new - { - episode_id = "123", - date_of_birth = "1970-01-01", - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); + ((IDictionary)_episode)[propertyName] = ""; + var request = _setupRequest.CreateMockHttpRequest(_episode); // Act var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("nhs_number is required", result.Value); + Assert.Equal($"{propertyName} is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsEmptyValue(string? nhsNumber) + [MemberData(nameof(RequiredPropertyNames))] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequiredPropertyIsWhitespace(string propertyName) { // Arrange - var episode = new - { - episode_id = "123", - nhs_number = nhsNumber, - date_of_birth = "1970-01-01", - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); + ((IDictionary)_episode)[propertyName] = " "; + var request = _setupRequest.CreateMockHttpRequest(_episode); // Act var response = await _functions.IngressEpisode(request); // Assert var result = Assert.IsType(response); - Assert.Equal("nhs_number is required", result.Value); + Assert.Equal($"{propertyName} is required", result.Value); _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } @@ -183,54 +164,6 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsInval _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } - [Fact] - public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsMissing() - { - // Arrange - var episode = new - { - episode_id = "123", - nhs_number = "9990000000", - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); - - // Act - var response = await _functions.IngressEpisode(request); - - // Assert - var result = Assert.IsType(response); - Assert.Equal("date_of_birth is required", result.Value); - _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsEmptyValue(string? dateOfBirth) - { - // Arrange - var episode = new - { - episode_id = "123", - nhs_number = "9990000000", - date_of_birth = dateOfBirth, - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); - - // Act - var response = await _functions.IngressEpisode(request); - - // Assert - var result = Assert.IsType(response); - Assert.Equal("date_of_birth is required", result.Value); - _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); - } - [Theory] [InlineData("ABC")] [InlineData("123")] @@ -256,115 +189,11 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsInv _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); } - [Fact] - public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFirstGivenNameIsMissing() - { - // Arrange - var episode = new - { - episode_id = "123", - nhs_number = "9990000000", - date_of_birth = "1970-01-01", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); - - // Act - var response = await _functions.IngressEpisode(request); - - // Assert - var result = Assert.IsType(response); - Assert.Equal("first_given_name is required", result.Value); - _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFirstGivenNameIsEmptyValue(string? firstGivenName) - { - // Arrange - var episode = new - { - episode_id = "123", - nhs_number = "9990000000", - date_of_birth = "1970-01-01", - first_given_name = firstGivenName, - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); - - // Act - var response = await _functions.IngressEpisode(request); - - // Assert - var result = Assert.IsType(response); - Assert.Equal("first_given_name is required", result.Value); - _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); - } - - [Fact] - public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFamilyNameIsMissing() - { - // Arrange - var episode = new - { - episode_id = "123", - nhs_number = "9990000000", - date_of_birth = "1970-01-01", - first_given_name = "Test" - }; - var request = _setupRequest.CreateMockHttpRequest(episode); - - // Act - var response = await _functions.IngressEpisode(request); - - // Assert - var result = Assert.IsType(response); - Assert.Equal("family_name is required", result.Value); - _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenFamilyNameIsEmptyValue(string? familyName) - { - // Arrange - var episode = new - { - episode_id = "123", - nhs_number = "9990000000", - date_of_birth = "1970-01-01", - first_given_name = "Test", - family_name = familyName, - }; - var request = _setupRequest.CreateMockHttpRequest(episode); - - // Act - var response = await _functions.IngressEpisode(request); - - // Assert - var result = Assert.IsType(response); - Assert.Equal("family_name is required", result.Value); - _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); - } - [Fact] public async Task CreateEpisodeEvent_ShouldReturnInternalServerError_WhenEventFailsToSend() { // Arrange - var episode = new - { - episode_id = "123", - nhs_number = "9990000000", - date_of_birth = "1970-01-01", - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); + var request = _setupRequest.CreateMockHttpRequest(_episode); var mockResponse = Mock.Of(r => r.IsError == true); _mockEventGridPublisherClient.Setup(x => x.SendEventAsync(It.IsAny(), It.IsAny())).ReturnsAsync(mockResponse); @@ -381,15 +210,7 @@ public async Task CreateEpisodeEvent_ShouldReturnInternalServerError_WhenEventFa public async Task CreateEpisodeEvent_ShouldReturnInternalServerError_WhenSendEventThrowsException() { // Arrange - var episode = new - { - episode_id = "123", - nhs_number = "9990000000", - date_of_birth = "1970-01-01", - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); + var request = _setupRequest.CreateMockHttpRequest(_episode); var mockResponse = Mock.Of(r => r.IsError == true); _mockEventGridPublisherClient.Setup(x => x.SendEventAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new RequestFailedException("Failed to send event to Event Grid")); From e3c67362ff443d6121834572f83a6e4a1cfbb38a Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Thu, 10 Apr 2025 08:28:17 +0100 Subject: [PATCH 18/20] test: Reused class level episode in remaining test methods --- .../Functions/BSSelectFunctionsTests.cs | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs index 469b500..4435a94 100644 --- a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs +++ b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs @@ -145,15 +145,8 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequiredProperty public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsInvalidValue(string? nhsNumber) { // Arrange - var episode = new - { - episode_id = "123", - nhs_number = nhsNumber, - date_of_birth = "1970-01-01", - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); + _episode.nhs_number = nhsNumber; + var request = _setupRequest.CreateMockHttpRequest(_episode); // Act var response = await _functions.IngressEpisode(request); @@ -170,15 +163,8 @@ public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenNhsNumberIsInval public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenDateOfBirthIsInvalidValue(string? dateOfBirth) { // Arrange - var episode = new - { - episode_id = "123", - nhs_number = "9990000000", - date_of_birth = dateOfBirth, - first_given_name = "Test", - family_name = "User", - }; - var request = _setupRequest.CreateMockHttpRequest(episode); + _episode.date_of_birth = dateOfBirth; + var request = _setupRequest.CreateMockHttpRequest(_episode); // Act var response = await _functions.IngressEpisode(request); From dd3e61b4e19a29adc99aeadd65e5f94c88b30a5a Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Thu, 10 Apr 2025 08:52:01 +0100 Subject: [PATCH 19/20] chore: Updated NuGet package versions --- src/ServiceLayer.API/ServiceLayer.API.csproj | 6 +++--- .../ServiceLayer.API.Tests/ServiceLayer.API.Tests.csproj | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ServiceLayer.API/ServiceLayer.API.csproj b/src/ServiceLayer.API/ServiceLayer.API.csproj index f7eb804..65d9fe3 100644 --- a/src/ServiceLayer.API/ServiceLayer.API.csproj +++ b/src/ServiceLayer.API/ServiceLayer.API.csproj @@ -10,10 +10,10 @@ - - + + - + diff --git a/tests/ServiceLayer.API.Tests/ServiceLayer.API.Tests.csproj b/tests/ServiceLayer.API.Tests/ServiceLayer.API.Tests.csproj index 4fe2a71..1fb53ab 100644 --- a/tests/ServiceLayer.API.Tests/ServiceLayer.API.Tests.csproj +++ b/tests/ServiceLayer.API.Tests/ServiceLayer.API.Tests.csproj @@ -8,11 +8,11 @@ - - + + - - + + From 08e1d21bf018f3f3c15fe032c6080a8882a3a5b6 Mon Sep 17 00:00:00 2001 From: alex-clayton-1 Date: Thu, 10 Apr 2025 17:02:53 +0100 Subject: [PATCH 20/20] test: Improved test to assert cloudevent contents --- .../Functions/BSSelectFunctionsTests.cs | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs index 4435a94..9e10323 100644 --- a/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs +++ b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs @@ -1,4 +1,5 @@ using System.Dynamic; +using System.Globalization; using Azure; using Azure.Messaging; using Azure.Messaging.EventGrid; @@ -7,6 +8,7 @@ using Microsoft.Extensions.Logging; using Moq; using ServiceLayer.API.Functions; +using ServiceLayer.API.Shared; using ServiceLayer.API.Tests.Utils; namespace ServiceLayer.API.Tests.Functions; @@ -45,14 +47,34 @@ public async Task CreateEpisodeEvent_ShouldSendEventAndReturnOk_WhenRequestIsVal // Arrange var request = _setupRequest.CreateMockHttpRequest(_episode); var mockResponse = Mock.Of(r => r.IsError == false); - _mockEventGridPublisherClient.Setup(x => x.SendEventAsync(It.IsAny(), It.IsAny())).ReturnsAsync(mockResponse); + CloudEvent? capturedEvent = null; + _mockEventGridPublisherClient.Setup(x => x.SendEventAsync(It.IsAny(), It.IsAny())) + .Callback((ce, _) => + { + capturedEvent = ce; + }) + .ReturnsAsync(mockResponse); // Act var response = await _functions.IngressEpisode(request); // Assert Assert.IsType(response); - _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), default), Times.Once()); + _mockEventGridPublisherClient.Verify(x => + x.SendEventAsync(It.Is(ce => + ce.Type == "EpisodeEvent" && + ce.Source == "ServiceLayer" && + ce.Data != null + ), default), Times.Once()); + + var data = capturedEvent!.Data!.ToObjectFromJson(); + Assert.NotNull(data); + Assert.Equal(new Guid("11111111-1111-1111-1111-111111111113"), data.PathwayTypeId); + Assert.Equal("Breast Screening Routine", data.PathwayTypeName); + Assert.Equal("Breast Screening", data.ScreeningName); + Assert.Equal(_episode.nhs_number, data.NhsNumber); + Assert.Equal(DateOnly.Parse(_episode.date_of_birth, CultureInfo.CurrentCulture), data.DOB); + Assert.Equal($"{_episode.first_given_name} {_episode.family_name}", data.Name); } [Fact]