diff --git a/Apps/Find/src/SUI.Find.FindApi/Startup/AzureStorageQueueStartup.cs b/Apps/Find/src/SUI.Find.FindApi/Startup/AzureStorageQueueStartup.cs index fef2d5aeb..e3b149271 100644 --- a/Apps/Find/src/SUI.Find.FindApi/Startup/AzureStorageQueueStartup.cs +++ b/Apps/Find/src/SUI.Find.FindApi/Startup/AzureStorageQueueStartup.cs @@ -3,28 +3,15 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using SUI.Find.Application.Constants; +using SUI.Find.Infrastructure.Factories; namespace SUI.Find.FindApi.Startup; [ExcludeFromCodeCoverage(Justification = "Hosted service startup code.")] -public class AzureStorageQueueStartup : IHostedService +public class AzureStorageQueueStartup(IQueueClientFactory queueClientFactory) : IHostedService { - private readonly IConfiguration _configuration; - - public AzureStorageQueueStartup(IConfiguration configuration) - { - _configuration = configuration; - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - var connectionString = _configuration["AzureWebJobsStorage"]; - var queueClient = new QueueClient( - connectionString, - ApplicationConstants.SearchJobs.QueueName - ); - await queueClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken); - } + public async Task StartAsync(CancellationToken cancellationToken) => + await queueClientFactory.CreateQueuesIfNotExistsAsync(cancellationToken); public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/Apps/Find/src/SUI.Find.Infrastructure/Extensions/ServiceCollectionExtensions.cs b/Apps/Find/src/SUI.Find.Infrastructure/Extensions/ServiceCollectionExtensions.cs index 4024bc9d6..dbfee3745 100644 --- a/Apps/Find/src/SUI.Find.Infrastructure/Extensions/ServiceCollectionExtensions.cs +++ b/Apps/Find/src/SUI.Find.Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using SUI.Find.Infrastructure.Configuration; using SUI.Find.Infrastructure.Factories; using SUI.Find.Infrastructure.Factories.Fhir; +using SUI.Find.Infrastructure.Handlers; using SUI.Find.Infrastructure.Interfaces; using SUI.Find.Infrastructure.Interfaces.Fhir; using SUI.Find.Infrastructure.Repositories.JobRepository; @@ -67,6 +68,7 @@ IConfiguration configuration services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/Apps/Find/src/SUI.Find.Infrastructure/Factories/QueueClientFactory.cs b/Apps/Find/src/SUI.Find.Infrastructure/Factories/QueueClientFactory.cs index 16a1db228..ccb29db1f 100644 --- a/Apps/Find/src/SUI.Find.Infrastructure/Factories/QueueClientFactory.cs +++ b/Apps/Find/src/SUI.Find.Infrastructure/Factories/QueueClientFactory.cs @@ -8,8 +8,29 @@ namespace SUI.Find.Infrastructure.Factories; public interface IQueueClientFactory { + /// + /// Creates the required queues. If any queue already exists, it is not changed. + /// + /// Optional token to propagate notifications that the operation should be cancelled. + /// Async operation task + Task CreateQueuesIfNotExistsAsync(CancellationToken cancellationToken); + + /// + /// Creates a client for sending messages to the Audit queue. + /// + /// Audit queue client IAuditQueueSender GetAuditClient(); + + /// + /// Creates a client for sending messages to the Search Job queue. + /// + /// Search Job queue client ISearchJobQueueSender GetSearchJobClient(); + + /// + /// Creates a client for sending messages to the Job Results queue. + /// + /// Job Results queue client IJobResultsQueueSender GetJobResultsClient(); } @@ -22,30 +43,35 @@ public class QueueClientFactory(IConfiguration config) : IQueueClientFactory private readonly string _azureWebJobsStorageConnectionString = config["AzureWebJobsStorage"] ?? throw new InvalidOperationException(); - public IAuditQueueSender GetAuditClient() + /// + public async Task CreateQueuesIfNotExistsAsync(CancellationToken cancellationToken) { - return new AzureQueueSender( - new QueueClient(_auditConnectionString, ApplicationConstants.Audit.AccessQueueName) - ); - } + await CreateAuditQueueClient().CreateIfNotExistsAsync(cancellationToken: cancellationToken); - public ISearchJobQueueSender GetSearchJobClient() - { - return new AzureSearchJobQueueSender( - new QueueClient( - _azureWebJobsStorageConnectionString, - ApplicationConstants.SearchJobs.QueueName - ) - ); - } + await CreateSearchJobQueueClient() + .CreateIfNotExistsAsync(cancellationToken: cancellationToken); - public IJobResultsQueueSender GetJobResultsClient() - { - return new AzureQueueSender( - new QueueClient( - _azureWebJobsStorageConnectionString, - ApplicationConstants.Jobs.JobResultsQueueName - ) - ); + await CreateJobResultsQueueClient() + .CreateIfNotExistsAsync(cancellationToken: cancellationToken); } + + /// + public IAuditQueueSender GetAuditClient() => new AzureQueueSender(CreateAuditQueueClient()); + + /// + public ISearchJobQueueSender GetSearchJobClient() => + new AzureSearchJobQueueSender(CreateSearchJobQueueClient()); + + /// + public IJobResultsQueueSender GetJobResultsClient() => + new AzureQueueSender(CreateJobResultsQueueClient()); + + private QueueClient CreateAuditQueueClient() => + new(_auditConnectionString, ApplicationConstants.Audit.AccessQueueName); + + private QueueClient CreateSearchJobQueueClient() => + new(_azureWebJobsStorageConnectionString, ApplicationConstants.SearchJobs.QueueName); + + private QueueClient CreateJobResultsQueueClient() => + new(_azureWebJobsStorageConnectionString, ApplicationConstants.Jobs.JobResultsQueueName); } diff --git a/Apps/Find/src/SUI.Find.Infrastructure/Services/JobSearchService.cs b/Apps/Find/src/SUI.Find.Infrastructure/Services/JobSearchService.cs index 213ba6b41..69dd62bfd 100644 --- a/Apps/Find/src/SUI.Find.Infrastructure/Services/JobSearchService.cs +++ b/Apps/Find/src/SUI.Find.Infrastructure/Services/JobSearchService.cs @@ -34,7 +34,8 @@ await workItemJobCountRepository.GetByWorkItemIdAndJobTypeAsync( if (workItemJobCountEntity == null || workItemJobCountEntity.ExpectedJobCount == 0) { - logger.LogInformation("No jobs found for work item ID {WorkItemId}", workItemId); + if (logger.IsEnabled(LogLevel.Information)) + logger.LogInformation("No jobs found for work item ID {WorkItemId}", workItemId); return new NotFound(); } @@ -55,8 +56,10 @@ await workItemJobCountRepository.GetByWorkItemIdAndJobTypeAsync( cancellationToken ); + var completedJobCount = completedRecords.DistinctBy(record => record.JobId).Count(); + var completenessPercentage = - completedRecords.Count * 100 / workItemJobCountEntity.ExpectedJobCount; + completedJobCount * 100 / workItemJobCountEntity.ExpectedJobCount; var status = GetOverallJobStatus(completenessPercentage, workItemJobCountEntity); diff --git a/Apps/Find/tests/SUI.Find.Infrastructure.UnitTests/Services/JobSearchServiceTests.cs b/Apps/Find/tests/SUI.Find.Infrastructure.UnitTests/Services/JobSearchServiceTests.cs index 421cdd75b..c6d44658e 100644 --- a/Apps/Find/tests/SUI.Find.Infrastructure.UnitTests/Services/JobSearchServiceTests.cs +++ b/Apps/Find/tests/SUI.Find.Infrastructure.UnitTests/Services/JobSearchServiceTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using NSubstitute; using OneOf.Types; +using Shouldly; using SUI.Find.Application.Dtos; using SUI.Find.Application.Enums; using SUI.Find.Application.Interfaces; @@ -27,6 +28,7 @@ public class JobSearchServiceTests public JobSearchServiceTests() { + _logger.IsEnabled(LogLevel.Information).Returns(true); _jobWindowStartService.GetWindowStart().Returns(_dateTime.AddHours(-72)); _sut = new JobSearchService( _searchResultEntryRepository, @@ -211,6 +213,90 @@ public async Task GetSearchResults_CorrectlyReturnsCompletedResults() Assert.Equal(searchResults, results.AsT0.Items); } + [Fact] + public async Task GetSearchResults_UsesDistinctJobCount_ToDeriveCompletenessPercentage() + { + var workItemId = $"wi-{Guid.NewGuid()}"; + var searchingOrganisationId = $"so-{Guid.NewGuid()}"; + + var payload = new CustodianLookupJobPayload("SUI-1", "Health"); + + var workItemJobCount = new WorkItemJobCount + { + WorkItemId = workItemId, + JobType = JobType.CustodianLookup, + SearchingOrganisationId = searchingOrganisationId, + PayloadJson = JsonSerializer.Serialize(payload, JsonSerializerOptions.Web), + ExpectedJobCount = 2, + CreatedAtUtc = _dateTime.AddHours(-6), + UpdatedAtUtc = _dateTime.AddHours(-2), + }; + + _workItemJobCountRepository + .GetByWorkItemIdAndJobTypeAsync( + workItemId, + JobType.CustodianLookup, + Arg.Any() + ) + .Returns(workItemJobCount); + + IReadOnlyList searchResults = + [ + new() + { + CustodianId = "CUS-1", + CustodianName = "Custodian-1", + JobId = "JOB-1", + WorkItemId = workItemId, + RecordType = "HEALTH", + SystemId = "SYS-1", + RecordUrl = "URL-1", + RecordId = "12345", + SearchingOrganisationId = searchingOrganisationId, + }, + new() + { + CustodianId = "CUS-1", + CustodianName = "Custodian-1", + JobId = "JOB-1", + WorkItemId = workItemId, + RecordType = "HEALTH", + SystemId = "SYS-2", + RecordUrl = "URL-2", + RecordId = "xyz", + SearchingOrganisationId = searchingOrganisationId, + }, + new() + { + CustodianId = "CUS-1", + CustodianName = "Custodian-1", + JobId = "JOB-1", + WorkItemId = workItemId, + RecordType = "HEALTH", + SystemId = "SYS-3", + RecordUrl = "URL-3", + RecordId = "efg", + SearchingOrganisationId = searchingOrganisationId, + }, + ]; + + _searchResultEntryRepository + .GetByWorkItemIdAsync(workItemId, searchingOrganisationId, Arg.Any()) + .Returns(searchResults); + + var results = await _sut.GetSearchResultsAsync( + workItemId, + searchingOrganisationId, + CancellationToken.None + ); + + var resultsDto = Assert.IsType(results.Value); + resultsDto.CompletenessPercentage.ShouldBe(50); + resultsDto.Status.ShouldBe(SearchStatus.Running); + resultsDto.WorkItemId.ShouldBe(workItemId); + resultsDto.Suid.ShouldBe("SUI-1"); + } + [Fact] public async Task GetSearchResults_CorrectlyReturnsExpiredResults() { diff --git a/Apps/StubCustodians/src/SUI.StubCustodians.Application/Utilities/FindApiClient.cs b/Apps/StubCustodians/src/SUI.StubCustodians.Application/Utilities/FindApiClient.cs index df5a30fb6..884f63e90 100644 --- a/Apps/StubCustodians/src/SUI.StubCustodians.Application/Utilities/FindApiClient.cs +++ b/Apps/StubCustodians/src/SUI.StubCustodians.Application/Utilities/FindApiClient.cs @@ -18,10 +18,10 @@ public FindApiClient(HttpClient http) public async Task ClaimAsync(string token) { - var req = new HttpRequestMessage(HttpMethod.Post, "/v2/work/claim"); + using var req = new HttpRequestMessage(HttpMethod.Post, "/api/v2/work/claim"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - var res = await _http.SendAsync(req); + using var res = await _http.SendAsync(req); if (res.StatusCode == HttpStatusCode.NoContent) { @@ -37,11 +37,15 @@ public FindApiClient(HttpClient http) public async Task SubmitAsync(string token, SubmitJobResultsRequest request) { - var req = new HttpRequestMessage(HttpMethod.Post, "/v2/work/result"); + using var req = new HttpRequestMessage(HttpMethod.Post, "/api/v2/work/result"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - req.Content = JsonContent.Create(request); - var res = await _http.SendAsync(req); + using var content = JsonContent.Create(request); + await content.LoadIntoBufferAsync(); // Local only concern, stops the HTTP client using chunked transfer encoding, which is not supported by the receiving end (Azure Functions Core Tools local dev host) + + req.Content = content; + + using var res = await _http.SendAsync(req); if (res.StatusCode == HttpStatusCode.Conflict) { diff --git a/Apps/StubCustodians/tests/SUI.StubCustodians.Application.Unit.Tests/Utilities/FindApiClientTests.cs b/Apps/StubCustodians/tests/SUI.StubCustodians.Application.Unit.Tests/Utilities/FindApiClientTests.cs index 28631f640..5f0562f9a 100644 --- a/Apps/StubCustodians/tests/SUI.StubCustodians.Application.Unit.Tests/Utilities/FindApiClientTests.cs +++ b/Apps/StubCustodians/tests/SUI.StubCustodians.Application.Unit.Tests/Utilities/FindApiClientTests.cs @@ -23,7 +23,7 @@ public async Task ClaimAsync_ShouldReturnJob_WhenJobAvailable() var handler = new FakeHandler(req => { - Assert.Equal("/v2/work/claim", req.RequestUri!.AbsolutePath); + Assert.Equal("/api/v2/work/claim", req.RequestUri!.AbsolutePath); Assert.Equal("Bearer", req.Headers.Authorization!.Scheme); Assert.Equal("token", req.Headers.Authorization.Parameter); @@ -82,7 +82,7 @@ public async Task SubmitAsync_ShouldPostResults() { var handler = new FakeHandler(req => { - Assert.Equal("/v2/work/result", req.RequestUri!.AbsolutePath); + Assert.Equal("/api/v2/work/result", req.RequestUri!.AbsolutePath); Assert.Equal("Bearer", req.Headers.Authorization!.Scheme); Assert.Equal("token", req.Headers.Authorization.Parameter);