diff --git a/.github/actions/perform-static-analysis/action.yaml b/.github/actions/perform-static-analysis/action.yaml index eca1719..86a8a20 100644 --- a/.github/actions/perform-static-analysis/action.yaml +++ b/.github/actions/perform-static-analysis/action.yaml @@ -52,11 +52,6 @@ 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 - ./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/api/ServiceLayer.API.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 ../.. + dotnet build src/ServiceLayer.sln + ./.sonar/scanner/dotnet-coverage collect -f xml -o coverage.xml dotnet test src/ServiceLayer.sln ./.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="${{ inputs.sonar_token }}" 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/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/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..19750bd --- /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..1037dd3 --- /dev/null +++ b/api/openapi/openapi.yaml @@ -0,0 +1,68 @@ +openapi: 3.0.3 +info: + title: Service Layer 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 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/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 diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 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"] diff --git a/src/ServiceLayer.API/Functions/BSSelectFunctions.cs b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs new file mode 100644 index 0000000..f42acb5 --- /dev/null +++ b/src/ServiceLayer.API/Functions/BSSelectFunctions.cs @@ -0,0 +1,78 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.Json; +using Azure.Messaging; +using Azure.Messaging.EventGrid; +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; + +namespace ServiceLayer.API.Functions; + +public class BSSelectFunctions(ILogger logger, EventGridPublisherClient eventGridPublisherClient) +{ + [Function("BSSelectIngressEpisode")] + public async Task IngressEpisode([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bsselect/episodes/ingress")] HttpRequestData req) + { + BSSelectEpisode? bssEpisodeEvent; + + try + { + bssEpisodeEvent = await JsonSerializer.DeserializeAsync(req.Body); + + if (bssEpisodeEvent == null) + { + logger.LogError("Deserialization returned null"); + return new BadRequestObjectResult("Deserialization returned null"); + } + + 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 + { + PathwayTypeId = new Guid("11111111-1111-1111-1111-111111111113"), + PathwayTypeName = "Breast Screening Routine", + ScreeningName = "Breast Screening", + NhsNumber = bssEpisodeEvent.NhsNumber!, + DOB = DateOnly.Parse(bssEpisodeEvent.DateOfBirth!, CultureInfo.CurrentCulture), + Name = $"{bssEpisodeEvent.FirstGivenName} {bssEpisodeEvent.FamilyName}", + }; + + var cloudEvent = new CloudEvent( + "ServiceLayer", + "EpisodeEvent", + 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: {Status}", + cloudEvent.Source, cloudEvent.Type, response.Status); + return new StatusCodeResult(500); + } + + return new OkResult(); + } + catch (Exception ex) + { + 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 new file mode 100644 index 0000000..7fdf99b --- /dev/null +++ b/src/ServiceLayer.API/Models/BSSelectEpisode.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using ServiceLayer.API.Shared; + +namespace ServiceLayer.API.Models; + +public class BSSelectEpisode +{ + [JsonPropertyName("episode_id")] + [Required(ErrorMessage = "episode_id is required")] + public string? EpisodeId { get; set; } + + [JsonPropertyName("nhs_number")] + [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 = "date_of_birth is required")] + [ValidDateOnly(ErrorMessage = "date_of_birth is invalid")] + public string? DateOfBirth { get; set; } + + [JsonPropertyName("first_given_name")] + [Required(ErrorMessage = "first_given_name is required")] + public string? FirstGivenName { get; set; } + + [JsonPropertyName("family_name")] + [Required(ErrorMessage = "family_name is required")] + public 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..2223411 --- /dev/null +++ b/src/ServiceLayer.API/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "ServiceLayer.API": { + "commandName": "Project", + "commandLineArgs": "--port 7001", + "launchBrowser": false + } + } +} diff --git a/src/ServiceLayer.API/ServiceLayer.API.csproj b/src/ServiceLayer.API/ServiceLayer.API.csproj new file mode 100644 index 0000000..65d9fe3 --- /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..cb630c2 --- /dev/null +++ b/src/ServiceLayer.API/Shared/CreatePathwayParticipantDto.cs @@ -0,0 +1,11 @@ +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 required string NhsNumber { get; set; } + public required DateOnly DOB { get; set; } + public required string Name { get; set; } +} diff --git a/src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs b/src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs new file mode 100644 index 0000000..58c34e2 --- /dev/null +++ b/src/ServiceLayer.API/Shared/ValidDateOnlyAttribute.cs @@ -0,0 +1,18 @@ +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, CultureInfo.CurrentCulture, out _)) + { + return true; + } + + return false; + } +} 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..0e4aad3 --- /dev/null +++ b/src/ServiceLayer.sln @@ -0,0 +1,56 @@ + +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} + EndGlobalSection +EndGlobal 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..9e10323 --- /dev/null +++ b/tests/ServiceLayer.API.Tests/Functions/BSSelectFunctionsTests.cs @@ -0,0 +1,234 @@ +using System.Dynamic; +using System.Globalization; +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.Shared; +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(); + 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 request = _setupRequest.CreateMockHttpRequest(_episode); + var mockResponse = Mock.Of(r => r.IsError == false); + 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.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] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequestBodyEmpty() + { + // Arrange + var request = _setupRequest.CreateMockHttpRequest(null); + + // Act + var response = await _functions.IngressEpisode(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()); + } + + [Theory] + [MemberData(nameof(RequiredPropertyNames))] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequiredPropertyIsMissing(string propertyName) + { + // Arrange + ((IDictionary)_episode).Remove(propertyName); + var request = _setupRequest.CreateMockHttpRequest(_episode); + + // Act + var response = await _functions.IngressEpisode(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal($"{propertyName} is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Theory] + [MemberData(nameof(RequiredPropertyNames))] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequiredPropertyIsNull(string propertyName) + { + // Arrange + ((IDictionary)_episode)[propertyName] = null; + var request = _setupRequest.CreateMockHttpRequest(_episode); + + // Act + var response = await _functions.IngressEpisode(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal($"{propertyName} is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Theory] + [MemberData(nameof(RequiredPropertyNames))] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequiredPropertyIsEmptyString(string propertyName) + { + // Arrange + ((IDictionary)_episode)[propertyName] = ""; + var request = _setupRequest.CreateMockHttpRequest(_episode); + + // Act + var response = await _functions.IngressEpisode(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal($"{propertyName} is required", result.Value); + _mockEventGridPublisherClient.Verify(x => x.SendEventAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Theory] + [MemberData(nameof(RequiredPropertyNames))] + public async Task CreateEpisodeEvent_ShouldReturnBadRequest_WhenRequiredPropertyIsWhitespace(string propertyName) + { + // Arrange + ((IDictionary)_episode)[propertyName] = " "; + var request = _setupRequest.CreateMockHttpRequest(_episode); + + // Act + var response = await _functions.IngressEpisode(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal($"{propertyName} 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 + _episode.nhs_number = nhsNumber; + var request = _setupRequest.CreateMockHttpRequest(_episode); + + // Act + var response = await _functions.IngressEpisode(request); + + // Assert + var result = Assert.IsType(response); + Assert.Equal("nhs_number must be exactly 10 digits", result.Value); + _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 + _episode.date_of_birth = dateOfBirth; + 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_ShouldReturnInternalServerError_WhenEventFailsToSend() + { + // Arrange + 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.IngressEpisode(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 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.IngressEpisode(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..1fb53ab --- /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; + } +}