diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 08cc6ae..e771596 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -91,6 +91,7 @@ jobs: - name: "Checkout code" uses: actions/checkout@v4 with: + submodules: 'true' fetch-depth: 0 # Full history is needed to improving relevancy of reporting - name: "Perform static analysis" uses: ./.github/actions/perform-static-analysis diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5c6205a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,5 @@ +[submodule "src/dotnet-mesh-client"] + # path = src/Shared/dotnet-mesh-client + path = src/dotnet-mesh-client + url = https://github.com/NHSDigital/dotnet-mesh-client.git + branch = main diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 957d73f..b4bc0fd 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,7 @@ { "recommendations": [ + "ms-azuretools.vscode-azurefunctions", + "ms-dotnettools.csharp", "alefragnani.bookmarks", "davidanson.vscode-markdownlint", "dbaeumer.vscode-eslint", diff --git a/src/ServiceLayer.Mesh/Data/DesignTimeDbContextFactory.cs b/src/ServiceLayer.Mesh/Data/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..569baf9 --- /dev/null +++ b/src/ServiceLayer.Mesh/Data/DesignTimeDbContextFactory.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using ServiceLayer.Mesh.Data; + +namespace ParticipantManager.API.Data; + +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public ServiceLayerDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("DatabaseConnectionString"); + if (string.IsNullOrEmpty(connectionString)) + { + throw new InvalidOperationException("Connection string 'DatabaseConnectionString' is not configured."); + } + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlServer(connectionString); + + return new ServiceLayerDbContext(optionsBuilder.Options); + } +} diff --git a/src/ServiceLayer.Mesh/Data/ServiceLayerDbConext.cs b/src/ServiceLayer.Mesh/Data/ServiceLayerDbConext.cs new file mode 100644 index 0000000..de65f7e --- /dev/null +++ b/src/ServiceLayer.Mesh/Data/ServiceLayerDbConext.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using ServiceLayer.Mesh.Models; + +namespace ServiceLayer.Mesh.Data; + +public class ServiceLayerDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet MeshFiles { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Configure relationships, keys, etc. + modelBuilder.Entity().HasKey(p => p.FileId); + modelBuilder.Entity().Property(e => e.Status).HasConversion(); + modelBuilder.Entity().Property(e => e.FileType).HasConversion(); + } +} diff --git a/src/ServiceLayer.Mesh/Functions/DiscoveryFunction.cs b/src/ServiceLayer.Mesh/Functions/DiscoveryFunction.cs new file mode 100644 index 0000000..ae06365 --- /dev/null +++ b/src/ServiceLayer.Mesh/Functions/DiscoveryFunction.cs @@ -0,0 +1,69 @@ +using Azure.Storage.Queues; +using Microsoft.Azure.Functions.Worker; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NHS.MESH.Client.Contracts.Services; +using ServiceLayer.Mesh.Data; +using ServiceLayer.Mesh.Models; + +namespace ServiceLayer.Mesh.Functions +{ + public class DiscoveryFunction + { + private readonly ILogger _logger; + private readonly IMeshInboxService _meshInboxService; + private readonly ServiceLayerDbContext _serviceLayerDbContext; + private readonly QueueClient _queueClient; + + public DiscoveryFunction(ILogger logger, IMeshInboxService meshInboxService, ServiceLayerDbContext serviceLayerDbContext, QueueClient queueClient) + { + _logger = logger; + _meshInboxService = meshInboxService; + _serviceLayerDbContext = serviceLayerDbContext; + _queueClient = queueClient; + } + + [Function("DiscoveryFunction")] + public async Task Run([TimerTrigger("%DiscoveryTimerExpression%")] TimerInfo myTimer) + { + _logger.LogInformation($"DiscoveryFunction started at: {DateTime.Now}"); + + var mailboxId = Environment.GetEnvironmentVariable("BSSMailBox") + ?? throw new InvalidOperationException($"Environment variable 'BSSMailBox' is not set or is empty."); + + var response = await _meshInboxService.GetMessagesAsync(mailboxId); + + _queueClient.CreateIfNotExists(); + + foreach (var messageId in response.Response.Messages) + { + using var transaction = await _serviceLayerDbContext.Database.BeginTransactionAsync(); + + var existing = await _serviceLayerDbContext.MeshFiles + .AnyAsync(f => f.FileId == messageId); + + if (!existing) + { + _serviceLayerDbContext.MeshFiles.Add(new MeshFile + { + FileId = messageId, + FileType = MeshFileType.NbssAppointmentEvents, + MailboxId = mailboxId, + Status = MeshFileStatus.Discovered, + FirstSeenUtc = DateTime.UtcNow, + LastUpdatedUtc = DateTime.UtcNow + }); + + await _serviceLayerDbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + + _queueClient.SendMessage(messageId); + } + else + { + await transaction.RollbackAsync(); + } + } + } + } +} diff --git a/src/ServiceLayer.Mesh/Migrations/20250512113115_InitialCreate.Designer.cs b/src/ServiceLayer.Mesh/Migrations/20250512113115_InitialCreate.Designer.cs new file mode 100644 index 0000000..9e7f7de --- /dev/null +++ b/src/ServiceLayer.Mesh/Migrations/20250512113115_InitialCreate.Designer.cs @@ -0,0 +1,66 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ServiceLayer.Mesh.Data; + +#nullable disable + +namespace ServiceLayer.Mesh.Migrations +{ + [DbContext(typeof(ServiceLayerDbContext))] + [Migration("20250512113115_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ServiceLayer.Mesh.Models.MeshFile", b => + { + b.Property("FileId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("BlobPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FirstSeenUtc") + .HasColumnType("datetime2"); + + b.Property("LastUpdatedUtc") + .HasColumnType("datetime2"); + + b.Property("MailboxId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("FileId"); + + b.ToTable("MeshFiles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceLayer.Mesh/Migrations/20250512113115_InitialCreate.cs b/src/ServiceLayer.Mesh/Migrations/20250512113115_InitialCreate.cs new file mode 100644 index 0000000..630118f --- /dev/null +++ b/src/ServiceLayer.Mesh/Migrations/20250512113115_InitialCreate.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceLayer.Mesh.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MeshFiles", + columns: table => new + { + FileId = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + FileType = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + MailboxId = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + BlobPath = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + FirstSeenUtc = table.Column(type: "datetime2", nullable: false), + LastUpdatedUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MeshFiles", x => x.FileId); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MeshFiles"); + } + } +} diff --git a/src/ServiceLayer.Mesh/Migrations/ServiceLayerDbContextModelSnapshot.cs b/src/ServiceLayer.Mesh/Migrations/ServiceLayerDbContextModelSnapshot.cs new file mode 100644 index 0000000..71cedc3 --- /dev/null +++ b/src/ServiceLayer.Mesh/Migrations/ServiceLayerDbContextModelSnapshot.cs @@ -0,0 +1,63 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ServiceLayer.Mesh.Data; + +#nullable disable + +namespace ServiceLayer.Mesh.Migrations +{ + [DbContext(typeof(ServiceLayerDbContext))] + partial class ServiceLayerDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ServiceLayer.Mesh.Models.MeshFile", b => + { + b.Property("FileId") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("BlobPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FirstSeenUtc") + .HasColumnType("datetime2"); + + b.Property("LastUpdatedUtc") + .HasColumnType("datetime2"); + + b.Property("MailboxId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("FileId"); + + b.ToTable("MeshFiles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceLayer.Mesh/Models/MeshFile.cs b/src/ServiceLayer.Mesh/Models/MeshFile.cs new file mode 100644 index 0000000..b7de2cb --- /dev/null +++ b/src/ServiceLayer.Mesh/Models/MeshFile.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore.Metadata.Internal; + +namespace ServiceLayer.Mesh.Models; + +public class MeshFile +{ + [MaxLength(255)] + public required string FileId { get; set; } + [MaxLength(50)] + public required MeshFileType FileType { get; set; } + [MaxLength(50)] + public required string MailboxId { get; set; } + [MaxLength(20)] + public required MeshFileStatus Status { get; set; } + [MaxLength(1024)] + public string? BlobPath { get; set; } + public DateTime FirstSeenUtc { get; set; } + public DateTime LastUpdatedUtc { get; set; } +} diff --git a/src/ServiceLayer.Mesh/Models/MeshFileStatus.cs b/src/ServiceLayer.Mesh/Models/MeshFileStatus.cs new file mode 100644 index 0000000..879abb9 --- /dev/null +++ b/src/ServiceLayer.Mesh/Models/MeshFileStatus.cs @@ -0,0 +1,12 @@ +namespace ServiceLayer.Mesh.Models; + +public enum MeshFileStatus +{ + Discovered, + Extracting, + Extracted, + Transforming, + Transformed, + FailedExtract, + FailedTransform +} diff --git a/src/ServiceLayer.Mesh/Models/MeshFileType.cs b/src/ServiceLayer.Mesh/Models/MeshFileType.cs new file mode 100644 index 0000000..c86748f --- /dev/null +++ b/src/ServiceLayer.Mesh/Models/MeshFileType.cs @@ -0,0 +1,6 @@ +namespace ServiceLayer.Mesh.Models; + +public enum MeshFileType +{ + NbssAppointmentEvents +} diff --git a/src/ServiceLayer.Mesh/Program.cs b/src/ServiceLayer.Mesh/Program.cs new file mode 100644 index 0000000..4ae7a2d --- /dev/null +++ b/src/ServiceLayer.Mesh/Program.cs @@ -0,0 +1,64 @@ +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Azure.Storage.Queues; +using Azure.Identity; +using Microsoft.EntityFrameworkCore; +using NHS.MESH.Client; +using ServiceLayer.Mesh.Data; + +var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .ConfigureServices(services => + { + // MESH Client config + services + .AddMeshClient(_ => _.MeshApiBaseUrl = Environment.GetEnvironmentVariable("MeshApiBaseUrl")) + .AddMailbox(Environment.GetEnvironmentVariable("BSSMailBox"), new NHS.MESH.Client.Configuration.MailboxConfiguration + { + Password = Environment.GetEnvironmentVariable("MeshPassword"), + SharedKey = Environment.GetEnvironmentVariable("MeshSharedKey"), + }).Build(); + + // EF Core DbContext + services.AddDbContext(options => + { + var connectionString = Environment.GetEnvironmentVariable("DatabaseConnectionString"); + if (string.IsNullOrEmpty(connectionString)) + throw new InvalidOperationException("The connection string has not been initialized."); + + options.UseSqlServer(connectionString); + }); + + // Register QueueClient as singleton + services.AddSingleton(provider => + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + if (environment == "Development") + { + return new QueueClient("UseDevelopmentStorage=true", "my-local-queue"); + } + else + { + var queueUrl = Environment.GetEnvironmentVariable("QueueUrl"); + + if (string.IsNullOrWhiteSpace(queueUrl)) + { + throw new InvalidOperationException("QueueUrl environment variable is not set."); + } + + var credential = new ManagedIdentityCredential(); + return new QueueClient(new Uri(queueUrl), credential); + } + }); + }); + + +// Application Insights isn't enabled by default. See https://aka.ms/AAt8mw4. +// builder.Services +// .AddApplicationInsightsTelemetryWorkerService() +// .ConfigureFunctionsApplicationInsights(); + +var app = host.Build(); +await app.RunAsync(); diff --git a/src/ServiceLayer.Mesh/Properties/launchSettings.json b/src/ServiceLayer.Mesh/Properties/launchSettings.json new file mode 100644 index 0000000..cd92488 --- /dev/null +++ b/src/ServiceLayer.Mesh/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "ServiceLayer.Mesh": { + "commandName": "Project", + "commandLineArgs": "--port 7142", + "launchBrowser": false + } + } +} diff --git a/src/ServiceLayer.Mesh/ServiceLayer.Mesh.csproj b/src/ServiceLayer.Mesh/ServiceLayer.Mesh.csproj new file mode 100644 index 0000000..da05f0e --- /dev/null +++ b/src/ServiceLayer.Mesh/ServiceLayer.Mesh.csproj @@ -0,0 +1,40 @@ + + + net9.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + + diff --git a/src/ServiceLayer.Mesh/host.json b/src/ServiceLayer.Mesh/host.json new file mode 100644 index 0000000..2babc58 --- /dev/null +++ b/src/ServiceLayer.Mesh/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 index 0e4aad3..74ff958 100644 --- a/src/ServiceLayer.sln +++ b/src/ServiceLayer.sln @@ -9,48 +9,77 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceLayer.API.Tests", "..\tests\ServiceLayer.API.Tests\ServiceLayer.API.Tests.csproj", "{BA052DAE-6FD1-483A-A0AF-DCBCF9E38C72}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceLayer.Mesh", "ServiceLayer.Mesh\ServiceLayer.Mesh.csproj", "{803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceLayer.Mesh.Tests", "..\tests\ServiceLayer.Mesh.Tests\ServiceLayer.Mesh.Tests.csproj", "{E5EF5B92-52DA-4EF3-956B-8AEE3D333428}" +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 + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Debug|x64.ActiveCfg = Debug|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Debug|x64.Build.0 = Debug|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Debug|x86.ActiveCfg = Debug|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Debug|x86.Build.0 = Debug|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Release|Any CPU.Build.0 = Release|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Release|x64.ActiveCfg = Release|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Release|x64.Build.0 = Release|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Release|x86.ActiveCfg = Release|Any CPU + {803E8A5E-A180-4799-8BE2-DD5BD3C34ED2}.Release|x86.Build.0 = Release|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Debug|x64.ActiveCfg = Debug|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Debug|x64.Build.0 = Debug|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Debug|x86.ActiveCfg = Debug|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Debug|x86.Build.0 = Debug|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Release|Any CPU.Build.0 = Release|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Release|x64.ActiveCfg = Release|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Release|x64.Build.0 = Release|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.Release|x86.ActiveCfg = Release|Any CPU + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428}.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} + {E5EF5B92-52DA-4EF3-956B-8AEE3D333428} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {EEE06B13-019F-4618-A6EB-FD834B6EA7D7} + EndGlobalSection EndGlobal diff --git a/src/dotnet-mesh-client b/src/dotnet-mesh-client new file mode 160000 index 0000000..4e8846b --- /dev/null +++ b/src/dotnet-mesh-client @@ -0,0 +1 @@ +Subproject commit 4e8846b8e6b3bb35bf29b25a2c1b91a5d07da1c2 diff --git a/tests/ServiceLayer.Mesh.Tests/Functions/DiscoveryFunctionTests.cs b/tests/ServiceLayer.Mesh.Tests/Functions/DiscoveryFunctionTests.cs new file mode 100644 index 0000000..450f0aa --- /dev/null +++ b/tests/ServiceLayer.Mesh.Tests/Functions/DiscoveryFunctionTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ServiceLayer.Mesh.Functions; +using ServiceLayer.Mesh.Models; +using ServiceLayer.Mesh.Data; +using Microsoft.EntityFrameworkCore; +using NHS.MESH.Client.Contracts.Services; +using Microsoft.Azure.Functions.Worker; +using NHS.MESH.Client.Models; +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; +using Azure; + +public class DiscoveryFunctionTests +{ + private readonly Mock> _loggerMock; + private readonly Mock _meshInboxServiceMock; + private readonly ServiceLayerDbContext _dbContext; + private readonly Mock _queueClientMock; + private readonly DiscoveryFunction _function; + + public DiscoveryFunctionTests() + { + _loggerMock = new Mock>(); + _meshInboxServiceMock = new Mock(); + _queueClientMock = new Mock(); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(warnings => + warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + _dbContext = new ServiceLayerDbContext(options); + + Environment.SetEnvironmentVariable("MailboxId", "test-mailbox"); + Environment.SetEnvironmentVariable("QueueUrl", "https://fakestorageaccount.queue.core.windows.net/testqueue"); + + _function = new DiscoveryFunction( + _loggerMock.Object, + _meshInboxServiceMock.Object, + _dbContext, + _queueClientMock.Object + ); + } + + [Fact] + public async Task Run_AddsNewMessageToDbAndQueue() + { + // Arrange + var testMessageId = "test-message-123"; + + _meshInboxServiceMock.Setup(s => s.GetMessagesAsync("test-mailbox")) + .ReturnsAsync(new MeshResponse + { + Response = new CheckInboxResponse { Messages = new[] { testMessageId } } + }); + + // Act + await _function.Run(null); + + // Assert + var meshFile = _dbContext.MeshFiles.FirstOrDefault(f => f.FileId == testMessageId); + Assert.NotNull(meshFile); + Assert.Equal(MeshFileStatus.Discovered, meshFile.Status); + Assert.Equal("test-mailbox", meshFile.MailboxId); + + _queueClientMock.Verify(q => q.SendMessage(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Run_DoesNotAddDuplicateMessageOrQueueIt() + { + // Arrange + var duplicateMessageId = "existing-message"; + _dbContext.MeshFiles.Add(new MeshFile + { + FileId = duplicateMessageId, + FileType = MeshFileType.NbssAppointmentEvents, + MailboxId = "test-mailbox", + Status = MeshFileStatus.Discovered, + FirstSeenUtc = DateTime.UtcNow, + LastUpdatedUtc = DateTime.UtcNow + }); + await _dbContext.SaveChangesAsync(); + + _meshInboxServiceMock.Setup(s => s.GetMessagesAsync("test-mailbox")) + .ReturnsAsync(new MeshResponse + { + Response = new CheckInboxResponse { Messages = new[] { duplicateMessageId } } + }); + + // Act + await _function.Run(null); + + // Assert + var count = _dbContext.MeshFiles.Count(f => f.FileId == duplicateMessageId); + Assert.Equal(1, count); + + _queueClientMock.Verify(q => q.SendMessage(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Run_NoMessagesInInbox_DoesNothing() + { + // Arrange + _meshInboxServiceMock.Setup(s => s.GetMessagesAsync("test-mailbox")) + .ReturnsAsync(new MeshResponse + { + Response = new CheckInboxResponse { Messages = Array.Empty() } + }); + + // Act + await _function.Run(null); + + // Assert + Assert.Empty(_dbContext.MeshFiles); + _queueClientMock.Verify(q => q.SendMessage(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Run_MultipleMessagesInInbox_AllAreProcessed() + { + // Arrange + var messageIds = new[] { "msg-1", "msg-2", "msg-3" }; + + _meshInboxServiceMock.Setup(s => s.GetMessagesAsync("test-mailbox")) + .ReturnsAsync(new MeshResponse + { + Response = new CheckInboxResponse { Messages = messageIds } + }); + + // Act + await _function.Run(null); + + // Assert + foreach (var id in messageIds) + { + var meshFile = _dbContext.MeshFiles.FirstOrDefault(f => f.FileId == id); + Assert.NotNull(meshFile); + Assert.Equal(MeshFileStatus.Discovered, meshFile.Status); + Assert.Equal("test-mailbox", meshFile.MailboxId); + } + + _queueClientMock.Verify(q => q.SendMessage(It.IsAny()), Times.Exactly(messageIds.Length)); + } +} diff --git a/tests/ServiceLayer.Mesh.Tests/ServiceLayer.Mesh.Tests.csproj b/tests/ServiceLayer.Mesh.Tests/ServiceLayer.Mesh.Tests.csproj new file mode 100644 index 0000000..6c3d85a --- /dev/null +++ b/tests/ServiceLayer.Mesh.Tests/ServiceLayer.Mesh.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + +